From 79e633e151c491802637d3e7d147620f6aae9fc5 Mon Sep 17 00:00:00 2001 From: Be-HinD Date: Mon, 29 Dec 2025 23:39:18 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:feat:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20v1=20=EA=B0=9C=EB=B0=9C=20-=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20Group=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=A0=81=EC=9A=A9=20-=20REST=20C?= =?UTF-8?q?ontroller=20(ChatRoomController)=20-=20/api/v1/chat=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20-=20Request/Response=20DTOs=20-=20WebSocket=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20(STOMP)=20-=20Service=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20(ChatRoomService,=20ChatMessageService)=20-=20yml?= =?UTF-8?q?=20=EB=B0=8F=20Security=20=EC=84=A4=EC=A0=95=20-=20Swagger=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20(ChatRoomControllerDocs)=20-=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=AC=B8=EC=84=9C=20=EB=B0=8F=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + ...04\355\202\244\355\205\215\354\262\230.md" | 356 ++++++++++++++ ...64\353\223\234\353\254\270\354\204\234.md" | 440 ++++++++++++++++++ .../dto/request/CreateDmRequest.java | 9 + .../dto/request/KickParticipantRequest.java | 9 + .../dto/request/SendMessageRequest.java | 13 + .../dto/response/ChatMessagePayload.java | 45 ++ .../dto/response/ChatRoomItemResponse.java | 35 ++ .../dto/response/ChatRoomListResponse.java | 11 + .../dto/response/ChatRoomResponse.java | 34 ++ .../dto/response/KickNotificationPayload.java | 22 + .../dto/response/LastMessageResponse.java | 18 + .../dto/response/MessageListResponse.java | 17 + .../dto/response/MessageResponse.java | 27 ++ .../dto/response/ParticipantListResponse.java | 16 + .../dto/response/ParticipantResponse.java | 25 + .../dto/response/ReadStatusResponse.java | 11 + .../listener/ChatEventListener.java | 92 ++++ .../service/ChatMessageService.java | 140 ++++++ .../application/service/ChatRoomService.java | 339 ++++++++++++++ .../chat/config/ChatProperties.java | 62 +++ .../chat/config/StompChannelInterceptor.java | 68 +++ .../chat/config/WebSocketConfig.java | 47 ++ .../chat/domain/entity/ChatMessage.java | 86 ++++ .../chat/domain/entity/ChatParticipant.java | 118 +++++ .../chat/domain/entity/ChatRoom.java | 99 ++++ .../chat/domain/entity/ChatType.java | 6 + .../chat/domain/entity/JoinType.java | 6 + .../chat/domain/entity/MessageType.java | 6 + .../chat/domain/entity/ParticipantStatus.java | 7 + .../chat/domain/exception/ChatErrorCode.java | 34 ++ .../chat/domain/exception/ChatException.java | 15 + .../repository/ChatMessageRepository.java | 50 ++ .../repository/ChatParticipantRepository.java | 30 ++ .../domain/repository/ChatRoomRepository.java | 42 ++ .../presentation/ChatMessageController.java | 91 ++++ .../chat/presentation/ChatRoomController.java | 140 ++++++ .../presentation/ChatRoomControllerDocs.java | 189 ++++++++ .../common/security/SecurityEndpoints.java | 3 + src/main/resources/application.yml | 22 +- src/test/http/auth/auth-api.http | 4 +- 41 files changed, 2782 insertions(+), 3 deletions(-) create mode 100644 "docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\354\225\204\355\202\244\355\205\215\354\262\230.md" create mode 100644 "docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270_\352\260\200\354\235\264\353\223\234\353\254\270\354\204\234.md" create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/request/CreateDmRequest.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/request/KickParticipantRequest.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/request/SendMessageRequest.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatMessagePayload.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomItemResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomListResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/KickNotificationPayload.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/LastMessageResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageListResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantListResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/dto/response/ReadStatusResponse.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/listener/ChatEventListener.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/service/ChatMessageService.java create mode 100644 src/main/java/team/wego/wegobackend/chat/application/service/ChatRoomService.java create mode 100644 src/main/java/team/wego/wegobackend/chat/config/ChatProperties.java create mode 100644 src/main/java/team/wego/wegobackend/chat/config/StompChannelInterceptor.java create mode 100644 src/main/java/team/wego/wegobackend/chat/config/WebSocketConfig.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/entity/ChatMessage.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/entity/ChatParticipant.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/entity/ChatRoom.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/entity/ChatType.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/entity/JoinType.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/entity/MessageType.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/entity/ParticipantStatus.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/exception/ChatErrorCode.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/exception/ChatException.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/repository/ChatMessageRepository.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/repository/ChatParticipantRepository.java create mode 100644 src/main/java/team/wego/wegobackend/chat/domain/repository/ChatRoomRepository.java create mode 100644 src/main/java/team/wego/wegobackend/chat/presentation/ChatMessageController.java create mode 100644 src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomController.java create mode 100644 src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomControllerDocs.java diff --git a/build.gradle b/build.gradle index 506a1f3..84275f3 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git "a/docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\354\225\204\355\202\244\355\205\215\354\262\230.md" "b/docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\354\225\204\355\202\244\355\205\215\354\262\230.md" new file mode 100644 index 0000000..176d565 --- /dev/null +++ "b/docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\354\225\204\355\202\244\355\205\215\354\262\230.md" @@ -0,0 +1,356 @@ +# 채팅 기능 아키텍처 문서 + +## 1. 개요 + +WeGo 애플리케이션의 실시간 채팅 기능입니다. 그룹 채팅(모임 기반)과 1:1 DM 채팅을 지원합니다. + +### 주요 기능 +- 모임 생성 시 그룹 채팅방 자동 생성 +- 모임 참여 시 채팅방 자동 참여 +- 1:1 DM 채팅 +- 실시간 메시지 송수신 (WebSocket/STOMP) +- 읽음 처리 및 안읽은 메시지 카운트 +- 참여자 추방 기능 + +--- + +## 2. 패키지 구조 + +``` +chat/ +├── application/ +│ ├── dto/ +│ │ ├── request/ +│ │ │ ├── CreateDmRequest.java # DM 채팅방 생성 요청 +│ │ │ ├── KickParticipantRequest.java # 참여자 추방 요청 +│ │ │ └── SendMessageRequest.java # 메시지 전송 요청 (WebSocket) +│ │ └── response/ +│ │ ├── ChatMessagePayload.java # WebSocket 메시지 페이로드 +│ │ ├── ChatRoomItemResponse.java # 채팅방 목록 아이템 +│ │ ├── ChatRoomListResponse.java # 채팅방 목록 +│ │ ├── ChatRoomResponse.java # 채팅방 상세 +│ │ ├── KickNotificationPayload.java # 추방 알림 페이로드 +│ │ ├── LastMessageResponse.java # 마지막 메시지 정보 +│ │ ├── MessageListResponse.java # 메시지 목록 (페이징) +│ │ ├── MessageResponse.java # 메시지 상세 +│ │ ├── ParticipantListResponse.java # 참여자 목록 +│ │ ├── ParticipantResponse.java # 참여자 정보 +│ │ └── ReadStatusResponse.java # 읽음 상태 +│ ├── listener/ +│ │ └── ChatEventListener.java # 모임 이벤트 리스너 +│ └── service/ +│ ├── ChatMessageService.java # 메시지 관리 서비스 +│ └── ChatRoomService.java # 채팅방 관리 서비스 +├── config/ +│ ├── ChatProperties.java # 채팅 설정 (application.yml) +│ ├── StompChannelInterceptor.java # WebSocket 인증 인터셉터 +│ └── WebSocketConfig.java # WebSocket 설정 +├── domain/ +│ ├── entity/ +│ │ ├── ChatMessage.java # 메시지 엔티티 +│ │ ├── ChatParticipant.java # 참여자 엔티티 +│ │ ├── ChatRoom.java # 채팅방 엔티티 +│ │ ├── ChatType.java # GROUP, DM +│ │ ├── JoinType.java # AUTO, MANUAL +│ │ ├── MessageType.java # TEXT, SYSTEM +│ │ └── ParticipantStatus.java # ACTIVE, LEFT, KICKED +│ ├── exception/ +│ │ ├── ChatErrorCode.java # 에러 코드 정의 +│ │ └── ChatException.java # 채팅 예외 +│ └── repository/ +│ ├── ChatMessageRepository.java +│ ├── ChatParticipantRepository.java +│ └── ChatRoomRepository.java +└── presentation/ + ├── ChatMessageController.java # WebSocket 메시지 핸들러 + └── ChatRoomController.java # REST API 컨트롤러 +``` + +--- + +## 3. 데이터베이스 스키마 + +### chat_room (채팅방) +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| chat_room_id | BIGINT | PK | +| chat_type | ENUM('GROUP', 'DM') | 채팅방 타입 | +| group_id | BIGINT | FK (그룹 채팅만) | +| expires_at | TIMESTAMP | 만료 시간 | +| created_at | TIMESTAMP | 생성 시간 | +| updated_at | TIMESTAMP | 수정 시간 | + +### chat_participant (참여자) +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| participant_id | BIGINT | PK | +| chat_room_id | BIGINT | FK | +| user_id | BIGINT | FK | +| joined_at | TIMESTAMP | 참여 시간 | +| join_type | ENUM('AUTO', 'MANUAL') | 참여 방식 | +| last_read_message_id | BIGINT | 마지막 읽은 메시지 | +| status | ENUM('ACTIVE', 'LEFT', 'KICKED') | 참여 상태 | +| updated_at | TIMESTAMP | 수정 시간 | + +### chat_message (메시지) +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| message_id | BIGINT | PK | +| chat_room_id | BIGINT | FK | +| sender_id | BIGINT | FK (시스템 메시지는 NULL) | +| content | TEXT | 메시지 내용 | +| message_type | ENUM('TEXT', 'SYSTEM') | 메시지 타입 | +| created_at | TIMESTAMP | 전송 시간 | + +--- + +## 4. REST API + +### 4.1 채팅방 목록 조회 +``` +GET /api/v1/chat/rooms +Authorization: Bearer {token} +``` + +**Response:** +```json +{ + "status": 200, + "success": true, + "data": { + "chatRooms": [ + { + "chatRoomId": 1, + "chatType": "GROUP", + "chatRoomName": "강남 러닝 모임", + "groupId": 10, + "participantCount": 5, + "lastMessage": { + "content": "내일 뵙겠습니다!", + "senderName": "홍길동", + "timestamp": "2025-12-29T10:30:00" + }, + "unreadCount": 3, + "updatedAt": "2025-12-29T10:30:00" + } + ] + } +} +``` + +### 4.2 채팅방 상세 조회 +``` +GET /api/v1/chat/rooms/{roomId} +Authorization: Bearer {token} +``` + +### 4.3 메시지 이력 조회 (커서 기반 페이징) +``` +GET /api/v1/chat/rooms/{roomId}/messages?cursor={messageId}&size=50 +Authorization: Bearer {token} +``` + +**Response:** +```json +{ + "status": 200, + "success": true, + "data": { + "messages": [ + { + "messageId": 99, + "senderId": 5, + "senderName": "홍길동", + "senderProfileImage": "https://...", + "content": "안녕하세요!", + "messageType": "TEXT", + "createdAt": "2025-12-29T10:30:00" + } + ], + "hasNext": true, + "nextCursor": 50 + } +} +``` + +### 4.4 읽음 처리 +``` +PUT /api/v1/chat/rooms/{roomId}/read +Authorization: Bearer {token} +``` + +### 4.5 참여자 추방 (방장 전용) +``` +POST /api/v1/chat/rooms/{roomId}/kick +Authorization: Bearer {token} +Content-Type: application/json + +{ + "targetUserId": 7 +} +``` + +### 4.6 채팅방 나가기 +``` +POST /api/v1/chat/rooms/{roomId}/leave +Authorization: Bearer {token} +``` + +### 4.7 1:1 채팅방 생성/조회 +``` +POST /api/v1/chat/dm +Authorization: Bearer {token} +Content-Type: application/json + +{ + "targetUserId": 5 +} +``` + +### 4.8 참여자 목록 조회 +``` +GET /api/v1/chat/rooms/{roomId}/participants +Authorization: Bearer {token} +``` + +--- + +## 5. WebSocket (STOMP) + +### 5.1 연결 정보 +- **엔드포인트**: `/ws-chat` +- **SockJS 지원**: `/ws-chat` (withSockJS) + +### 5.2 인증 +STOMP CONNECT 시 Authorization 헤더에 JWT 토큰 전달: +``` +Authorization: Bearer {accessToken} +``` + +### 5.3 메시지 전송 +``` +SEND /pub/chat/message +{ + "chatRoomId": 1, + "content": "안녕하세요!" +} +``` + +### 5.4 구독 + +**채팅방 메시지 구독:** +``` +SUBSCRIBE /sub/chat/room/{roomId} +``` + +**개인 알림 구독 (추방 알림 등):** +``` +SUBSCRIBE /sub/user/{userId} +``` + +### 5.5 메시지 페이로드 + +**일반 메시지:** +```json +{ + "messageId": 123, + "chatRoomId": 1, + "senderId": 5, + "senderName": "홍길동", + "senderProfileImage": "https://...", + "content": "안녕하세요!", + "messageType": "TEXT", + "timestamp": "2025-12-29T10:30:00" +} +``` + +**시스템 메시지:** +```json +{ + "messageId": 124, + "chatRoomId": 1, + "senderId": null, + "senderName": null, + "content": "홍길동님이 입장했습니다", + "messageType": "SYSTEM", + "timestamp": "2025-12-29T10:30:00" +} +``` + +**추방 알림:** +```json +{ + "type": "KICKED", + "chatRoomId": 1, + "message": "채팅방에서 퇴장되었습니다", + "timestamp": "2025-12-29T10:30:00" +} +``` + +--- + +## 6. 이벤트 연동 + +### 6.1 모임 생성 → 채팅방 자동 생성 +``` +GroupCreatedEvent → ChatEventListener.handleGroupCreated() + → ChatRoomService.createGroupChatRoomForMeeting() +``` + +### 6.2 모임 참여 → 채팅방 자동 참여 +``` +GroupJoinedEvent → ChatEventListener.handleGroupJoined() + → ChatRoomService.joinChatRoomByGroup() +``` + +### 6.3 모임 퇴장 → 채팅방 퇴장 +``` +GroupLeftEvent → ChatEventListener.handleGroupLeft() + → ChatRoomService.leaveChatRoomByGroup() +``` + +--- + +## 7. 설정 (application.yml) + +```yaml +chat: + auto-join: + enabled: true # 모임 참여 시 채팅방 자동 참여 + dm: + delete-policy: NONE # NONE | INACTIVE_DAYS + inactive-days: 90 + websocket: + endpoint: /ws-chat + allowed-origins: + - http://localhost:3000 + - http://localhost:8080 + message: + max-length: 1000 + batch: + delete-expired-chat: + cron: "0 0 * * * *" +``` + +--- + +## 8. 에러 코드 + +| 코드 | HTTP Status | 메시지 | +|------|-------------|--------| +| CHAT_ROOM_NOT_FOUND | 404 | 채팅방을 찾을 수 없습니다 | +| NOT_CHAT_PARTICIPANT | 403 | 채팅방에 참여하지 않았습니다 | +| NOT_CHAT_ROOM_OWNER | 403 | 방장만 사용할 수 있는 기능입니다 | +| CANNOT_KICK_SELF | 400 | 자기 자신을 추방할 수 없습니다 | +| PARTICIPANT_KICKED | 403 | 채팅방에서 추방되었습니다 | +| MESSAGE_TOO_LONG | 400 | 메시지가 너무 깁니다 | +| MESSAGE_EMPTY | 400 | 메시지 내용을 입력해주세요 | +| CANNOT_DM_SELF | 400 | 자기 자신에게 메시지를 보낼 수 없습니다 | + +--- + +## 9. 미구현 기능 (TODO) + +- [ ] 만료 채팅방 삭제 배치 작업 +- [ ] 메시지 검색 +- [ ] 파일/이미지 전송 diff --git "a/docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270_\352\260\200\354\235\264\353\223\234\353\254\270\354\204\234.md" "b/docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270_\352\260\200\354\235\264\353\223\234\353\254\270\354\204\234.md" new file mode 100644 index 0000000..23ce698 --- /dev/null +++ "b/docs/\354\261\204\355\214\205\354\204\234\353\271\204\354\212\244_\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270_\352\260\200\354\235\264\353\223\234\353\254\270\354\204\234.md" @@ -0,0 +1,440 @@ +# 채팅 기능 클라이언트 테스트 가이드 + +## 1. 사전 준비 + +### 1.1 서버 실행 +```bash +./gradlew bootRun +``` + +### 1.2 JWT 토큰 발급 +로그인 API를 통해 accessToken을 발급받습니다. + +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "password123" + }' +``` + +**Response:** +```json +{ + "status": 200, + "success": true, + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", + "expiresIn": 3600 + } +} +``` + +--- + +## 2. REST API 테스트 + +### 2.1 채팅방 목록 조회 +```bash +curl -X GET http://localhost:8080/api/v1/chat/rooms \ + -H "Authorization: Bearer {accessToken}" +``` + +### 2.2 1:1 채팅방 생성 +```bash +curl -X POST http://localhost:8080/api/v1/chat/dm \ + -H "Authorization: Bearer {accessToken}" \ + -H "Content-Type: application/json" \ + -d '{ + "targetUserId": 2 + }' +``` + +### 2.3 메시지 이력 조회 +```bash +# 첫 페이지 +curl -X GET "http://localhost:8080/api/v1/chat/rooms/1/messages?size=20" \ + -H "Authorization: Bearer {accessToken}" + +# 다음 페이지 (커서 사용) +curl -X GET "http://localhost:8080/api/v1/chat/rooms/1/messages?cursor=50&size=20" \ + -H "Authorization: Bearer {accessToken}" +``` + +### 2.4 읽음 처리 +```bash +curl -X PUT http://localhost:8080/api/v1/chat/rooms/1/read \ + -H "Authorization: Bearer {accessToken}" +``` + +### 2.5 채팅방 나가기 +```bash +curl -X POST http://localhost:8080/api/v1/chat/rooms/1/leave \ + -H "Authorization: Bearer {accessToken}" +``` + +### 2.6 참여자 추방 (방장만) +```bash +curl -X POST http://localhost:8080/api/v1/chat/rooms/1/kick \ + -H "Authorization: Bearer {accessToken}" \ + -H "Content-Type: application/json" \ + -d '{ + "targetUserId": 3 + }' +``` + +--- + +## 3. WebSocket 테스트 + +### 3.1 브라우저 개발자 도구에서 테스트 + +브라우저 콘솔에서 다음 코드를 실행합니다. + +#### SockJS + STOMP 라이브러리 로드 +```javascript +// SockJS 로드 +const sockjsScript = document.createElement('script'); +sockjsScript.src = 'https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js'; +document.head.appendChild(sockjsScript); + +// STOMP 로드 +const stompScript = document.createElement('script'); +stompScript.src = 'https://cdn.jsdelivr.net/npm/@stomp/stompjs@7/bundles/stomp.umd.min.js'; +document.head.appendChild(stompScript); +``` + +#### WebSocket 연결 및 메시지 전송 +```javascript +// 토큰 설정 (로그인 후 받은 accessToken) +const accessToken = 'YOUR_ACCESS_TOKEN_HERE'; + +// SockJS 연결 +const socket = new SockJS('http://localhost:8080/ws-chat'); +const stompClient = Stomp.over(socket); + +// 연결 +stompClient.connect( + { Authorization: `Bearer ${accessToken}` }, + function(frame) { + console.log('Connected: ' + frame); + + // 채팅방 구독 (roomId = 1) + stompClient.subscribe('/sub/chat/room/1', function(message) { + const payload = JSON.parse(message.body); + console.log('Received message:', payload); + }); + + // 개인 알림 구독 (userId = 본인 ID) + stompClient.subscribe('/sub/user/1', function(notification) { + const payload = JSON.parse(notification.body); + console.log('Received notification:', payload); + }); + + console.log('Subscribed to chat room and user notifications'); + }, + function(error) { + console.error('Connection error:', error); + } +); + +// 메시지 전송 함수 +function sendMessage(chatRoomId, content) { + stompClient.send('/pub/chat/message', {}, JSON.stringify({ + chatRoomId: chatRoomId, + content: content + })); + console.log('Message sent:', content); +} + +// 연결 해제 함수 +function disconnect() { + if (stompClient !== null) { + stompClient.disconnect(); + } + console.log('Disconnected'); +} +``` + +#### 메시지 전송 테스트 +```javascript +// 채팅방 1에 메시지 전송 +sendMessage(1, '안녕하세요!'); +sendMessage(1, '테스트 메시지입니다.'); +``` + +--- + +### 3.2 Postman에서 WebSocket 테스트 + +1. Postman에서 **New > WebSocket Request** 선택 +2. URL 입력: `ws://localhost:8080/ws-chat` +3. **Connect** 클릭 + +**STOMP CONNECT 프레임:** +``` +CONNECT +Authorization:Bearer YOUR_ACCESS_TOKEN +accept-version:1.1,1.0 +heart-beat:10000,10000 + +^@ +``` + +**채팅방 구독:** +``` +SUBSCRIBE +id:sub-0 +destination:/sub/chat/room/1 + +^@ +``` + +**메시지 전송:** +``` +SEND +destination:/pub/chat/message +content-type:application/json + +{"chatRoomId":1,"content":"Hello from Postman!"}^@ +``` + +> `^@`는 NULL 문자입니다. Postman에서는 자동으로 처리됩니다. + +--- + +### 3.3 wscat으로 테스트 (CLI) + +```bash +# wscat 설치 +npm install -g wscat + +# 연결 (SockJS 없이 순수 WebSocket) +wscat -c ws://localhost:8080/ws-chat +``` + +--- + +## 4. React/Next.js 클라이언트 예제 + +### 4.1 패키지 설치 +```bash +npm install sockjs-client @stomp/stompjs +``` + +### 4.2 채팅 훅 (useChatSocket.ts) +```typescript +import { useEffect, useRef, useState, useCallback } from 'react'; +import SockJS from 'sockjs-client'; +import { Client, IMessage } from '@stomp/stompjs'; + +interface ChatMessage { + messageId: number; + chatRoomId: number; + senderId: number | null; + senderName: string | null; + senderProfileImage: string | null; + content: string; + messageType: 'TEXT' | 'SYSTEM'; + timestamp: string; +} + +interface UseChatSocketOptions { + roomId: number; + userId: number; + accessToken: string; + onMessage?: (message: ChatMessage) => void; + onNotification?: (notification: any) => void; +} + +export function useChatSocket({ + roomId, + userId, + accessToken, + onMessage, + onNotification, +}: UseChatSocketOptions) { + const clientRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [messages, setMessages] = useState([]); + + useEffect(() => { + const client = new Client({ + webSocketFactory: () => new SockJS('http://localhost:8080/ws-chat'), + connectHeaders: { + Authorization: `Bearer ${accessToken}`, + }, + debug: (str) => console.log(str), + reconnectDelay: 5000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000, + }); + + client.onConnect = () => { + setIsConnected(true); + console.log('WebSocket connected'); + + // 채팅방 구독 + client.subscribe(`/sub/chat/room/${roomId}`, (message: IMessage) => { + const payload: ChatMessage = JSON.parse(message.body); + setMessages((prev) => [...prev, payload]); + onMessage?.(payload); + }); + + // 개인 알림 구독 + client.subscribe(`/sub/user/${userId}`, (message: IMessage) => { + const payload = JSON.parse(message.body); + onNotification?.(payload); + }); + }; + + client.onDisconnect = () => { + setIsConnected(false); + console.log('WebSocket disconnected'); + }; + + client.onStompError = (frame) => { + console.error('STOMP error:', frame); + }; + + client.activate(); + clientRef.current = client; + + return () => { + client.deactivate(); + }; + }, [roomId, userId, accessToken, onMessage, onNotification]); + + const sendMessage = useCallback((content: string) => { + if (clientRef.current?.connected) { + clientRef.current.publish({ + destination: '/pub/chat/message', + body: JSON.stringify({ + chatRoomId: roomId, + content, + }), + }); + } + }, [roomId]); + + return { + isConnected, + messages, + sendMessage, + }; +} +``` + +### 4.3 채팅 컴포넌트 예제 +```tsx +import React, { useState } from 'react'; +import { useChatSocket } from './useChatSocket'; + +interface ChatRoomProps { + roomId: number; + userId: number; + accessToken: string; +} + +export function ChatRoom({ roomId, userId, accessToken }: ChatRoomProps) { + const [inputValue, setInputValue] = useState(''); + + const { isConnected, messages, sendMessage } = useChatSocket({ + roomId, + userId, + accessToken, + onMessage: (msg) => console.log('New message:', msg), + onNotification: (notif) => { + if (notif.type === 'KICKED') { + alert('채팅방에서 퇴장되었습니다.'); + } + }, + }); + + const handleSend = () => { + if (inputValue.trim()) { + sendMessage(inputValue); + setInputValue(''); + } + }; + + return ( +
+
Status: {isConnected ? '연결됨' : '연결 중...'}
+ +
+ {messages.map((msg) => ( +
+ {msg.messageType === 'SYSTEM' ? ( + {msg.content} + ) : ( +
+ {msg.senderName}: {msg.content} +
+ )} +
+ ))} +
+ +
+ setInputValue(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSend()} + placeholder="메시지를 입력하세요" + /> + +
+
+ ); +} +``` + +--- + +## 5. 테스트 시나리오 + +### 5.1 그룹 채팅 테스트 +1. 모임 생성 → 자동으로 채팅방 생성 확인 +2. 다른 사용자가 모임 참여 → 채팅방에 자동 참여 확인 +3. WebSocket 연결 후 메시지 송수신 +4. 메시지 이력 조회 (페이징) +5. 읽음 처리 후 unreadCount 확인 + +### 5.2 DM 채팅 테스트 +1. POST /api/v1/chat/dm으로 DM 채팅방 생성 +2. 같은 상대에게 다시 요청 → 기존 채팅방 반환 확인 +3. 메시지 송수신 테스트 + +### 5.3 추방 테스트 +1. 방장이 참여자 추방 +2. 추방된 사용자가 `/sub/user/{userId}` 구독 중이면 KICKED 알림 수신 +3. 추방된 사용자의 채팅방 접근 차단 확인 + +### 5.4 에러 케이스 테스트 +1. 참여하지 않은 채팅방 접근 → 403 에러 +2. 자기 자신 추방 시도 → 400 에러 +3. 자기 자신에게 DM 생성 → 400 에러 +4. 1000자 초과 메시지 전송 → 400 에러 + +--- + +## 6. 문제 해결 + +### 6.1 WebSocket 연결 실패 +- CORS 설정 확인 (`chat.websocket.allowed-origins`) +- JWT 토큰 유효성 확인 +- 서버 로그에서 인증 에러 확인 + +### 6.2 메시지가 수신되지 않음 +- 올바른 destination으로 구독했는지 확인 +- roomId가 정확한지 확인 +- 채팅방 참여자인지 확인 + +### 6.3 401 Unauthorized +- accessToken이 만료되지 않았는지 확인 +- Authorization 헤더 형식: `Bearer {token}` diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/request/CreateDmRequest.java b/src/main/java/team/wego/wegobackend/chat/application/dto/request/CreateDmRequest.java new file mode 100644 index 0000000..140a3e5 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/request/CreateDmRequest.java @@ -0,0 +1,9 @@ +package team.wego.wegobackend.chat.application.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record CreateDmRequest( + @NotNull(message = "대상 사용자 ID는 필수입니다") + Long targetUserId +) { +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/request/KickParticipantRequest.java b/src/main/java/team/wego/wegobackend/chat/application/dto/request/KickParticipantRequest.java new file mode 100644 index 0000000..2641757 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/request/KickParticipantRequest.java @@ -0,0 +1,9 @@ +package team.wego.wegobackend.chat.application.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record KickParticipantRequest( + @NotNull(message = "추방 대상 사용자 ID는 필수입니다") + Long targetUserId +) { +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/request/SendMessageRequest.java b/src/main/java/team/wego/wegobackend/chat/application/dto/request/SendMessageRequest.java new file mode 100644 index 0000000..b11a7d9 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/request/SendMessageRequest.java @@ -0,0 +1,13 @@ +package team.wego.wegobackend.chat.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record SendMessageRequest( + @NotNull(message = "채팅방 ID는 필수입니다") + Long chatRoomId, + + @NotBlank(message = "메시지 내용은 필수입니다") + String content +) { +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatMessagePayload.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatMessagePayload.java new file mode 100644 index 0000000..07145cf --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatMessagePayload.java @@ -0,0 +1,45 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.time.LocalDateTime; +import team.wego.wegobackend.chat.domain.entity.ChatMessage; +import team.wego.wegobackend.chat.domain.entity.MessageType; + +/** + * WebSocket을 통해 클라이언트로 전송되는 메시지 페이로드 + */ +public record ChatMessagePayload( + Long messageId, + Long chatRoomId, + Long senderId, + String senderName, + String senderProfileImage, + String content, + MessageType messageType, + LocalDateTime timestamp +) { + public static ChatMessagePayload from(ChatMessage message) { + return new ChatMessagePayload( + message.getId(), + message.getChatRoom().getId(), + message.getSender() != null ? message.getSender().getId() : null, + message.getSender() != null ? message.getSender().getNickName() : null, + message.getSender() != null ? message.getSender().getProfileImage() : null, + message.getContent(), + message.getMessageType(), + message.getCreatedAt() + ); + } + + public static ChatMessagePayload systemMessage(Long chatRoomId, String content) { + return new ChatMessagePayload( + null, + chatRoomId, + null, + null, + null, + content, + MessageType.SYSTEM, + LocalDateTime.now() + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomItemResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomItemResponse.java new file mode 100644 index 0000000..9879efc --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomItemResponse.java @@ -0,0 +1,35 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.time.LocalDateTime; +import team.wego.wegobackend.chat.domain.entity.ChatRoom; +import team.wego.wegobackend.chat.domain.entity.ChatType; + +public record ChatRoomItemResponse( + Long chatRoomId, + ChatType chatType, + String chatRoomName, + Long groupId, + int participantCount, + LastMessageResponse lastMessage, + int unreadCount, + LocalDateTime updatedAt +) { + public static ChatRoomItemResponse of( + ChatRoom chatRoom, + String chatRoomName, + int participantCount, + LastMessageResponse lastMessage, + int unreadCount + ) { + return new ChatRoomItemResponse( + chatRoom.getId(), + chatRoom.getChatType(), + chatRoomName, + chatRoom.getGroup() != null ? chatRoom.getGroup().getId() : null, + participantCount, + lastMessage, + unreadCount, + chatRoom.getUpdatedAt() + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomListResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomListResponse.java new file mode 100644 index 0000000..a9ca753 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomListResponse.java @@ -0,0 +1,11 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.util.List; + +public record ChatRoomListResponse( + List chatRooms +) { + public static ChatRoomListResponse from(List chatRooms) { + return new ChatRoomListResponse(chatRooms); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomResponse.java new file mode 100644 index 0000000..bc2bca2 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ChatRoomResponse.java @@ -0,0 +1,34 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.time.LocalDateTime; +import java.util.List; +import team.wego.wegobackend.chat.domain.entity.ChatRoom; +import team.wego.wegobackend.chat.domain.entity.ChatType; + +public record ChatRoomResponse( + Long chatRoomId, + ChatType chatType, + String chatRoomName, + Long groupId, + int participantCount, + List participants, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static ChatRoomResponse of( + ChatRoom chatRoom, + String chatRoomName, + List participants + ) { + return new ChatRoomResponse( + chatRoom.getId(), + chatRoom.getChatType(), + chatRoomName, + chatRoom.getGroup() != null ? chatRoom.getGroup().getId() : null, + participants.size(), + participants, + chatRoom.getCreatedAt(), + chatRoom.getUpdatedAt() + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/KickNotificationPayload.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/KickNotificationPayload.java new file mode 100644 index 0000000..0dcc008 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/KickNotificationPayload.java @@ -0,0 +1,22 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.time.LocalDateTime; + +/** + * 추방 알림 페이로드 (WebSocket을 통해 추방된 사용자에게 전송) + */ +public record KickNotificationPayload( + String type, + Long chatRoomId, + String message, + LocalDateTime timestamp +) { + public static KickNotificationPayload of(Long chatRoomId) { + return new KickNotificationPayload( + "KICKED", + chatRoomId, + "채팅방에서 퇴장되었습니다", + LocalDateTime.now() + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/LastMessageResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/LastMessageResponse.java new file mode 100644 index 0000000..2b44d21 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/LastMessageResponse.java @@ -0,0 +1,18 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.time.LocalDateTime; +import team.wego.wegobackend.chat.domain.entity.ChatMessage; + +public record LastMessageResponse( + String content, + String senderName, + LocalDateTime timestamp +) { + public static LastMessageResponse from(ChatMessage message, String senderName) { + return new LastMessageResponse( + message.getContent(), + senderName, + message.getCreatedAt() + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageListResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageListResponse.java new file mode 100644 index 0000000..2ab2a62 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageListResponse.java @@ -0,0 +1,17 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.util.List; + +public record MessageListResponse( + List messages, + boolean hasNext, + Long nextCursor +) { + public static MessageListResponse of( + List messages, + boolean hasNext, + Long nextCursor + ) { + return new MessageListResponse(messages, hasNext, nextCursor); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageResponse.java new file mode 100644 index 0000000..33bb58e --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/MessageResponse.java @@ -0,0 +1,27 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.time.LocalDateTime; +import team.wego.wegobackend.chat.domain.entity.ChatMessage; +import team.wego.wegobackend.chat.domain.entity.MessageType; + +public record MessageResponse( + Long messageId, + Long senderId, + String senderName, + String senderProfileImage, + String content, + MessageType messageType, + LocalDateTime createdAt +) { + public static MessageResponse from(ChatMessage message) { + return new MessageResponse( + message.getId(), + message.getSender() != null ? message.getSender().getId() : null, + message.getSender() != null ? message.getSender().getNickName() : null, + message.getSender() != null ? message.getSender().getProfileImage() : null, + message.getContent(), + message.getMessageType(), + message.getCreatedAt() + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantListResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantListResponse.java new file mode 100644 index 0000000..502a1ec --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantListResponse.java @@ -0,0 +1,16 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.util.List; + +public record ParticipantListResponse( + Long chatRoomId, + int totalCount, + List participants +) { + public static ParticipantListResponse of( + Long chatRoomId, + List participants + ) { + return new ParticipantListResponse(chatRoomId, participants.size(), participants); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantResponse.java new file mode 100644 index 0000000..f94dc1b --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ParticipantResponse.java @@ -0,0 +1,25 @@ +package team.wego.wegobackend.chat.application.dto.response; + +import java.time.LocalDateTime; +import team.wego.wegobackend.chat.domain.entity.ChatParticipant; +import team.wego.wegobackend.chat.domain.entity.ParticipantStatus; + +public record ParticipantResponse( + Long participantId, + Long userId, + String nickName, + String profileImage, + ParticipantStatus status, + LocalDateTime joinedAt +) { + public static ParticipantResponse from(ChatParticipant participant) { + return new ParticipantResponse( + participant.getId(), + participant.getUser().getId(), + participant.getUser().getNickName(), + participant.getUser().getProfileImage(), + participant.getStatus(), + participant.getJoinedAt() + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/dto/response/ReadStatusResponse.java b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ReadStatusResponse.java new file mode 100644 index 0000000..4013035 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/dto/response/ReadStatusResponse.java @@ -0,0 +1,11 @@ +package team.wego.wegobackend.chat.application.dto.response; + +public record ReadStatusResponse( + Long chatRoomId, + Long lastReadMessageId, + int unreadCount +) { + public static ReadStatusResponse of(Long chatRoomId, Long lastReadMessageId, int unreadCount) { + return new ReadStatusResponse(chatRoomId, lastReadMessageId, unreadCount); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/listener/ChatEventListener.java b/src/main/java/team/wego/wegobackend/chat/application/listener/ChatEventListener.java new file mode 100644 index 0000000..c198649 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/listener/ChatEventListener.java @@ -0,0 +1,92 @@ +package team.wego.wegobackend.chat.application.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import team.wego.wegobackend.chat.application.service.ChatRoomService; +import team.wego.wegobackend.chat.config.ChatProperties; +import team.wego.wegobackend.chat.domain.entity.JoinType; +import team.wego.wegobackend.group.v2.application.event.GroupCreatedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupJoinedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupLeftEvent; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatEventListener { + + private final ChatRoomService chatRoomService; + private final ChatProperties chatProperties; + + /** + * 모임 생성 시 그룹 채팅방 자동 생성 + */ + @EventListener + @Async + public void handleGroupCreated(GroupCreatedEvent event) { + log.info("모임 생성 이벤트 수신 - groupId: {}, hostUserId: {}", + event.groupId(), event.hostUserId()); + + try { + chatRoomService.createGroupChatRoomForMeeting( + event.groupId(), + event.hostUserId() + ); + log.info("그룹 채팅방 생성 완료 - groupId: {}", event.groupId()); + } catch (Exception e) { + log.error("그룹 채팅방 생성 실패 - groupId: {}", event.groupId(), e); + } + } + + /** + * 모임 참여 시 채팅방 자동 참여 + */ + @EventListener + @Async + public void handleGroupJoined(GroupJoinedEvent event) { + log.info("모임 참여 이벤트 수신 - groupId: {}, joinerUserId: {}", + event.groupId(), event.joinerUserId()); + + if (!chatProperties.getAutoJoin().isEnabled()) { + log.debug("자동 참여 비활성화 - groupId: {}", event.groupId()); + return; + } + + try { + chatRoomService.joinChatRoomByGroup( + event.groupId(), + event.joinerUserId(), + JoinType.AUTO + ); + log.info("채팅방 자동 참여 완료 - groupId: {}, userId: {}", + event.groupId(), event.joinerUserId()); + } catch (Exception e) { + log.error("채팅방 자동 참여 실패 - groupId: {}, userId: {}", + event.groupId(), event.joinerUserId(), e); + } + } + + /** + * 모임 퇴장 시 채팅방 퇴장 처리 (선택 사항) + */ + @EventListener + @Async + public void handleGroupLeft(GroupLeftEvent event) { + log.info("모임 퇴장 이벤트 수신 - groupId: {}, leaverUserId: {}", + event.groupId(), event.leaverUserId()); + + try { + chatRoomService.leaveChatRoomByGroup( + event.groupId(), + event.leaverUserId() + ); + log.info("채팅방 퇴장 처리 완료 - groupId: {}, userId: {}", + event.groupId(), event.leaverUserId()); + } catch (Exception e) { + log.error("채팅방 퇴장 처리 실패 - groupId: {}, userId: {}", + event.groupId(), event.leaverUserId(), e); + } + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/service/ChatMessageService.java b/src/main/java/team/wego/wegobackend/chat/application/service/ChatMessageService.java new file mode 100644 index 0000000..c8fdfad --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/service/ChatMessageService.java @@ -0,0 +1,140 @@ +package team.wego.wegobackend.chat.application.service; + +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.chat.application.dto.response.ChatMessagePayload; +import team.wego.wegobackend.chat.application.dto.response.MessageListResponse; +import team.wego.wegobackend.chat.application.dto.response.MessageResponse; +import team.wego.wegobackend.chat.config.ChatProperties; +import team.wego.wegobackend.chat.domain.entity.ChatMessage; +import team.wego.wegobackend.chat.domain.entity.ChatRoom; +import team.wego.wegobackend.chat.domain.entity.ParticipantStatus; +import team.wego.wegobackend.chat.domain.exception.ChatErrorCode; +import team.wego.wegobackend.chat.domain.exception.ChatException; +import team.wego.wegobackend.chat.domain.repository.ChatMessageRepository; +import team.wego.wegobackend.chat.domain.repository.ChatParticipantRepository; +import team.wego.wegobackend.chat.domain.repository.ChatRoomRepository; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class ChatMessageService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final ChatMessageRepository chatMessageRepository; + private final UserRepository userRepository; + private final ChatProperties chatProperties; + + /** + * 메시지 전송 + */ + @Transactional + public ChatMessagePayload sendMessage(Long userId, Long roomId, String content) { + // 메시지 내용 검증 + validateMessageContent(content); + + // 채팅방 및 참여자 검증 + ChatRoom chatRoom = findChatRoomById(roomId); + validateParticipant(roomId, userId); + + User sender = findUserById(userId); + + // 메시지 생성 및 저장 + ChatMessage message = ChatMessage.createTextMessage(sender, content); + chatRoom.addMessage(message); + chatMessageRepository.save(message); + + log.debug("메시지 저장 - roomId: {}, messageId: {}, senderId: {}", + roomId, message.getId(), userId); + + return ChatMessagePayload.from(message); + } + + /** + * 시스템 메시지 전송 (입장/퇴장 등) + */ + @Transactional + public ChatMessagePayload sendSystemMessage(Long roomId, String content) { + ChatRoom chatRoom = findChatRoomById(roomId); + + ChatMessage message = ChatMessage.createSystemMessage(content); + chatRoom.addMessage(message); + chatMessageRepository.save(message); + + log.debug("시스템 메시지 저장 - roomId: {}, content: {}", roomId, content); + + return ChatMessagePayload.from(message); + } + + /** + * 메시지 이력 조회 (커서 기반 페이징) + */ + public MessageListResponse getMessages(Long userId, Long roomId, Long cursor, int size) { + // 참여자 검증 + validateParticipant(roomId, userId); + + PageRequest pageRequest = PageRequest.of(0, size); + Slice messageSlice; + + if (cursor == null) { + // 첫 페이지: 최신 메시지부터 + messageSlice = chatMessageRepository.findByChatRoomIdOrderByCreatedAtDesc(roomId, pageRequest); + } else { + // 이후 페이지: 커서 이전 메시지 + messageSlice = chatMessageRepository.findByChatRoomIdAndIdLessThan(roomId, cursor, pageRequest); + } + + List messages = messageSlice.getContent() + .stream() + .map(MessageResponse::from) + .collect(Collectors.toList()); + + Long nextCursor = messageSlice.hasNext() && !messages.isEmpty() + ? messages.get(messages.size() - 1).messageId() + : null; + + return MessageListResponse.of(messages, messageSlice.hasNext(), nextCursor); + } + + // ===== Private Helper Methods ===== + + private ChatRoom findChatRoomById(Long roomId) { + return chatRoomRepository.findById(roomId) + .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + } + + private void validateParticipant(Long roomId, Long userId) { + boolean isParticipant = chatParticipantRepository + .existsByChatRoomIdAndUserIdAndStatus(roomId, userId, ParticipantStatus.ACTIVE); + + if (!isParticipant) { + throw new ChatException(ChatErrorCode.NOT_CHAT_PARTICIPANT); + } + } + + private void validateMessageContent(String content) { + if (content == null || content.isBlank()) { + throw new ChatException(ChatErrorCode.MESSAGE_EMPTY); + } + + int maxLength = chatProperties.getMessage().getMaxLength(); + if (content.length() > maxLength) { + throw new ChatException(ChatErrorCode.MESSAGE_TOO_LONG, maxLength); + } + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/application/service/ChatRoomService.java b/src/main/java/team/wego/wegobackend/chat/application/service/ChatRoomService.java new file mode 100644 index 0000000..b7d85dc --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/application/service/ChatRoomService.java @@ -0,0 +1,339 @@ +package team.wego.wegobackend.chat.application.service; + +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.chat.application.dto.response.ChatRoomItemResponse; +import team.wego.wegobackend.chat.application.dto.response.ChatRoomListResponse; +import team.wego.wegobackend.chat.application.dto.response.ChatRoomResponse; +import team.wego.wegobackend.chat.application.dto.response.LastMessageResponse; +import team.wego.wegobackend.chat.application.dto.response.ParticipantListResponse; +import team.wego.wegobackend.chat.application.dto.response.ParticipantResponse; +import team.wego.wegobackend.chat.application.dto.response.ReadStatusResponse; +import team.wego.wegobackend.chat.domain.entity.ChatMessage; +import team.wego.wegobackend.chat.domain.entity.ChatParticipant; +import team.wego.wegobackend.chat.domain.entity.ChatRoom; +import team.wego.wegobackend.chat.domain.entity.ChatType; +import team.wego.wegobackend.chat.domain.entity.JoinType; +import team.wego.wegobackend.chat.domain.entity.ParticipantStatus; +import team.wego.wegobackend.chat.domain.exception.ChatErrorCode; +import team.wego.wegobackend.chat.domain.exception.ChatException; +import team.wego.wegobackend.chat.domain.repository.ChatMessageRepository; +import team.wego.wegobackend.chat.domain.repository.ChatParticipantRepository; +import team.wego.wegobackend.chat.domain.repository.ChatRoomRepository; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class ChatRoomService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final ChatMessageRepository chatMessageRepository; + private final UserRepository userRepository; + private final GroupV2Repository groupV2Repository; + + /** + * 내 채팅방 목록 조회 + */ + public ChatRoomListResponse getMyChatRooms(Long userId) { + List chatRooms = chatRoomRepository.findAllByUserIdAndActiveStatus(userId); + + List items = chatRooms.stream() + .map(chatRoom -> buildChatRoomItem(chatRoom, userId)) + .collect(Collectors.toList()); + + return ChatRoomListResponse.from(items); + } + + /** + * 채팅방 상세 조회 + */ + public ChatRoomResponse getChatRoom(Long userId, Long roomId) { + ChatRoom chatRoom = findChatRoomById(roomId); + validateParticipant(chatRoom.getId(), userId); + + List participants = chatParticipantRepository + .findActiveParticipants(roomId) + .stream() + .map(ParticipantResponse::from) + .collect(Collectors.toList()); + + String chatRoomName = resolveChatRoomName(chatRoom, userId); + + return ChatRoomResponse.of(chatRoom, chatRoomName, participants); + } + + /** + * 채팅방 참여자 목록 조회 + */ + public ParticipantListResponse getParticipants(Long userId, Long roomId) { + ChatRoom chatRoom = findChatRoomById(roomId); + validateParticipant(chatRoom.getId(), userId); + + List participants = chatParticipantRepository + .findActiveParticipants(roomId) + .stream() + .map(ParticipantResponse::from) + .collect(Collectors.toList()); + + return ParticipantListResponse.of(roomId, participants); + } + + /** + * 읽음 처리 + */ + @Transactional + public ReadStatusResponse markAsRead(Long userId, Long roomId) { + ChatParticipant participant = findParticipant(roomId, userId); + + ChatMessage latestMessage = chatMessageRepository.findLatestByChatRoomId(roomId) + .orElse(null); + + Long lastReadMessageId = latestMessage != null ? latestMessage.getId() : null; + + if (lastReadMessageId != null) { + participant.updateLastReadMessageId(lastReadMessageId); + } + + return ReadStatusResponse.of(roomId, lastReadMessageId, 0); + } + + /** + * 참여자 추방 (방장 전용) + */ + @Transactional + public void kickParticipant(Long userId, Long roomId, Long targetUserId) { + ChatRoom chatRoom = findChatRoomById(roomId); + + // 자기 자신 추방 방지 + if (userId.equals(targetUserId)) { + throw new ChatException(ChatErrorCode.CANNOT_KICK_SELF); + } + + // 그룹 채팅방인 경우 방장 권한 확인 + if (chatRoom.isGroupChat()) { + validateHost(chatRoom, userId); + } + + ChatParticipant targetParticipant = findParticipant(roomId, targetUserId); + targetParticipant.kick(); + + log.info("참여자 추방 - roomId: {}, targetUserId: {}, kickedBy: {}", roomId, targetUserId, userId); + } + + /** + * 채팅방 나가기 + */ + @Transactional + public void leaveChatRoom(Long userId, Long roomId) { + ChatParticipant participant = findParticipant(roomId, userId); + participant.leave(); + + log.info("채팅방 퇴장 - roomId: {}, userId: {}", roomId, userId); + } + + /** + * 1:1 채팅방 생성 또는 조회 + */ + @Transactional + public ChatRoomResponse createOrGetDmRoom(Long userId, Long targetUserId) { + // 자기 자신에게 DM 방지 + if (userId.equals(targetUserId)) { + throw new ChatException(ChatErrorCode.CANNOT_DM_SELF); + } + + User user = findUserById(userId); + User targetUser = findUserById(targetUserId); + + // 기존 DM 채팅방 조회 + return chatRoomRepository.findDmChatRoom(userId, targetUserId) + .map(chatRoom -> buildChatRoomResponse(chatRoom, userId)) + .orElseGet(() -> createDmRoom(user, targetUser)); + } + + /** + * 모임용 그룹 채팅방 생성 (이벤트 리스너에서 호출) + */ + @Transactional + public ChatRoom createGroupChatRoomForMeeting(Long groupId, Long hostUserId) { + GroupV2 group = groupV2Repository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("그룹을 찾을 수 없습니다: " + groupId)); + + User host = findUserById(hostUserId); + + // 이미 채팅방이 있는지 확인 + if (chatRoomRepository.findByGroupId(groupId).isPresent()) { + log.warn("이미 그룹 채팅방이 존재합니다 - groupId: {}", groupId); + return chatRoomRepository.findByGroupId(groupId).get(); + } + + ChatRoom chatRoom = ChatRoom.createGroupChat(group); + chatRoomRepository.save(chatRoom); + + // 방장을 참여자로 추가 + ChatParticipant hostParticipant = ChatParticipant.create(host, JoinType.AUTO); + chatRoom.addParticipant(hostParticipant); + + log.info("그룹 채팅방 생성 - groupId: {}, chatRoomId: {}", groupId, chatRoom.getId()); + + return chatRoom; + } + + /** + * 모임 참여 시 채팅방 자동 참여 (이벤트 리스너에서 호출) + */ + @Transactional + public void joinChatRoomByGroup(Long groupId, Long userId, JoinType joinType) { + ChatRoom chatRoom = chatRoomRepository.findByGroupId(groupId) + .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); + + User user = findUserById(userId); + + // 이미 참여 중인지 확인 + chatParticipantRepository.findByChatRoomIdAndUserId(chatRoom.getId(), userId) + .ifPresentOrElse( + participant -> { + if (!participant.isActive()) { + participant.rejoin(joinType); + log.info("채팅방 재참여 - roomId: {}, userId: {}", chatRoom.getId(), userId); + } + }, + () -> { + ChatParticipant newParticipant = ChatParticipant.create(user, joinType); + chatRoom.addParticipant(newParticipant); + log.info("채팅방 참여 - roomId: {}, userId: {}", chatRoom.getId(), userId); + } + ); + } + + /** + * 모임 퇴장 시 채팅방 퇴장 (이벤트 리스너에서 호출) + */ + @Transactional + public void leaveChatRoomByGroup(Long groupId, Long userId) { + chatRoomRepository.findByGroupId(groupId) + .ifPresent(chatRoom -> { + chatParticipantRepository.findByChatRoomIdAndUserId(chatRoom.getId(), userId) + .ifPresent(ChatParticipant::leave); + log.info("채팅방 퇴장 (모임 연동) - groupId: {}, userId: {}", groupId, userId); + }); + } + + // ===== Private Helper Methods ===== + + private ChatRoom findChatRoomById(Long roomId) { + return chatRoomRepository.findById(roomId) + .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); + } + + private ChatParticipant findParticipant(Long roomId, Long userId) { + return chatParticipantRepository.findByChatRoomIdAndUserId(roomId, userId) + .filter(ChatParticipant::isActive) + .orElseThrow(() -> new ChatException(ChatErrorCode.NOT_CHAT_PARTICIPANT)); + } + + private void validateParticipant(Long roomId, Long userId) { + boolean isParticipant = chatParticipantRepository + .existsByChatRoomIdAndUserIdAndStatus(roomId, userId, ParticipantStatus.ACTIVE); + + if (!isParticipant) { + throw new ChatException(ChatErrorCode.NOT_CHAT_PARTICIPANT); + } + } + + private void validateHost(ChatRoom chatRoom, Long userId) { + if (chatRoom.getGroup() == null) { + return; + } + + Long hostId = chatRoom.getGroup().getHost().getId(); + if (!hostId.equals(userId)) { + throw new ChatException(ChatErrorCode.NOT_CHAT_ROOM_OWNER); + } + } + + private ChatRoomItemResponse buildChatRoomItem(ChatRoom chatRoom, Long userId) { + String chatRoomName = resolveChatRoomName(chatRoom, userId); + int participantCount = chatParticipantRepository.countActiveParticipants(chatRoom.getId()); + + LastMessageResponse lastMessage = chatMessageRepository.findLatestByChatRoomId(chatRoom.getId()) + .map(msg -> LastMessageResponse.from( + msg, + msg.getSender() != null ? msg.getSender().getNickName() : null + )) + .orElse(null); + + int unreadCount = calculateUnreadCount(chatRoom.getId(), userId); + + return ChatRoomItemResponse.of(chatRoom, chatRoomName, participantCount, lastMessage, unreadCount); + } + + private ChatRoomResponse buildChatRoomResponse(ChatRoom chatRoom, Long userId) { + String chatRoomName = resolveChatRoomName(chatRoom, userId); + + List participants = chatParticipantRepository + .findActiveParticipants(chatRoom.getId()) + .stream() + .map(ParticipantResponse::from) + .collect(Collectors.toList()); + + return ChatRoomResponse.of(chatRoom, chatRoomName, participants); + } + + private String resolveChatRoomName(ChatRoom chatRoom, Long userId) { + if (chatRoom.getChatType() == ChatType.GROUP && chatRoom.getGroup() != null) { + return chatRoom.getGroup().getTitle(); + } + + // DM인 경우 상대방 이름 반환 + return chatParticipantRepository.findActiveParticipants(chatRoom.getId()) + .stream() + .filter(p -> !p.getUser().getId().equals(userId)) + .findFirst() + .map(p -> p.getUser().getNickName()) + .orElse("알 수 없음"); + } + + private int calculateUnreadCount(Long roomId, Long userId) { + return chatParticipantRepository.findByChatRoomIdAndUserId(roomId, userId) + .filter(ChatParticipant::isActive) + .map(participant -> { + Long lastReadId = participant.getLastReadMessageId(); + if (lastReadId == null) { + lastReadId = 0L; + } + return chatMessageRepository.countUnreadMessages(roomId, lastReadId); + }) + .orElse(0); + } + + private ChatRoomResponse createDmRoom(User user, User targetUser) { + ChatRoom chatRoom = ChatRoom.createDmChat(); + chatRoomRepository.save(chatRoom); + + ChatParticipant participant1 = ChatParticipant.create(user, JoinType.MANUAL); + ChatParticipant participant2 = ChatParticipant.create(targetUser, JoinType.MANUAL); + + chatRoom.addParticipant(participant1); + chatRoom.addParticipant(participant2); + + log.info("DM 채팅방 생성 - chatRoomId: {}, users: [{}, {}]", + chatRoom.getId(), user.getId(), targetUser.getId()); + + return buildChatRoomResponse(chatRoom, user.getId()); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/config/ChatProperties.java b/src/main/java/team/wego/wegobackend/chat/config/ChatProperties.java new file mode 100644 index 0000000..96f34a9 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/config/ChatProperties.java @@ -0,0 +1,62 @@ +package team.wego.wegobackend.chat.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "chat") +@Getter +@Setter +public class ChatProperties { + + private AutoJoin autoJoin = new AutoJoin(); + private Dm dm = new Dm(); + private Websocket websocket = new Websocket(); + private Message message = new Message(); + private Batch batch = new Batch(); + + @Getter + @Setter + public static class AutoJoin { + private boolean enabled = true; + } + + @Getter + @Setter + public static class Dm { + private DeletePolicy deletePolicy = DeletePolicy.NONE; + private int inactiveDays = 90; + } + + public enum DeletePolicy { + NONE, + INACTIVE_DAYS + } + + @Getter + @Setter + public static class Websocket { + private String endpoint = "/ws-chat"; + private String[] allowedOrigins = {"http://localhost:3000"}; + } + + @Getter + @Setter + public static class Message { + private int maxLength = 1000; + } + + @Getter + @Setter + public static class Batch { + private DeleteExpiredChat deleteExpiredChat = new DeleteExpiredChat(); + } + + @Getter + @Setter + public static class DeleteExpiredChat { + private String cron = "0 0 * * * *"; + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/config/StompChannelInterceptor.java b/src/main/java/team/wego/wegobackend/chat/config/StompChannelInterceptor.java new file mode 100644 index 0000000..56e6281 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/config/StompChannelInterceptor.java @@ -0,0 +1,68 @@ +package team.wego.wegobackend.chat.config; + +import java.security.Principal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; +import team.wego.wegobackend.common.security.jwt.JwtTokenProvider; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StompChannelInterceptor implements ChannelInterceptor { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + String authHeader = accessor.getFirstNativeHeader(AUTHORIZATION_HEADER); + + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + String token = authHeader.substring(BEARER_PREFIX.length()); + + try { + if (jwtTokenProvider.validateAccessToken(token)) { + Long userId = jwtTokenProvider.getTokenUserId(token); + String email = jwtTokenProvider.getEmailFromToken(token); + + // Principal로 사용자 정보 설정 + StompPrincipal principal = new StompPrincipal(userId, email); + accessor.setUser(principal); + + log.debug("WebSocket 연결 인증 성공 - userId: {}", userId); + } + } catch (Exception e) { + log.error("WebSocket 연결 인증 실패", e); + throw new IllegalArgumentException("Invalid token"); + } + } else { + log.warn("WebSocket 연결 시 Authorization 헤더 없음"); + throw new IllegalArgumentException("Missing authorization header"); + } + } + + return message; + } + + /** + * WebSocket 연결에서 사용하는 Principal 구현 + */ + public record StompPrincipal(Long userId, String email) implements Principal { + @Override + public String getName() { + return String.valueOf(userId); + } + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/config/WebSocketConfig.java b/src/main/java/team/wego/wegobackend/chat/config/WebSocketConfig.java new file mode 100644 index 0000000..b0500fb --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/config/WebSocketConfig.java @@ -0,0 +1,47 @@ +package team.wego.wegobackend.chat.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final ChatProperties chatProperties; + private final StompChannelInterceptor stompChannelInterceptor; + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 클라이언트가 구독할 수 있는 목적지 prefix + // /sub/chat/room/{roomId} - 채팅방 메시지 구독 + // /sub/user/{userId} - 개인 알림 구독 + registry.enableSimpleBroker("/sub"); + + // 클라이언트가 메시지를 보낼 때 사용하는 prefix + // /pub/chat/message - 메시지 전송 + registry.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // WebSocket 연결 엔드포인트 + registry.addEndpoint(chatProperties.getWebsocket().getEndpoint()) + .setAllowedOriginPatterns(chatProperties.getWebsocket().getAllowedOrigins()) + .withSockJS(); + + // SockJS 없이 연결 (네이티브 앱용) + registry.addEndpoint(chatProperties.getWebsocket().getEndpoint()) + .setAllowedOriginPatterns(chatProperties.getWebsocket().getAllowedOrigins()); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompChannelInterceptor); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatMessage.java b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatMessage.java new file mode 100644 index 0000000..967af20 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatMessage.java @@ -0,0 +1,86 @@ +package team.wego.wegobackend.chat.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import team.wego.wegobackend.user.domain.User; + +@Entity +@Table(name = "chat_message") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "message_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "message_type", nullable = false, length = 10) + private MessageType messageType; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Builder + private ChatMessage(ChatRoom chatRoom, User sender, String content, MessageType messageType) { + this.chatRoom = chatRoom; + this.sender = sender; + this.content = content; + this.messageType = messageType != null ? messageType : MessageType.TEXT; + this.createdAt = LocalDateTime.now(); + } + + public static ChatMessage createTextMessage(User sender, String content) { + return ChatMessage.builder() + .sender(sender) + .content(content) + .messageType(MessageType.TEXT) + .build(); + } + + public static ChatMessage createSystemMessage(String content) { + return ChatMessage.builder() + .sender(null) + .content(content) + .messageType(MessageType.SYSTEM) + .build(); + } + + void assignToChatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + } + + public boolean isSystemMessage() { + return messageType == MessageType.SYSTEM; + } + + public boolean isTextMessage() { + return messageType == MessageType.TEXT; + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatParticipant.java b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatParticipant.java new file mode 100644 index 0000000..47d4f1c --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatParticipant.java @@ -0,0 +1,118 @@ +package team.wego.wegobackend.chat.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import team.wego.wegobackend.user.domain.User; + +@Entity +@Table( + name = "chat_participant", + uniqueConstraints = @UniqueConstraint( + name = "uk_chat_room_user", + columnNames = {"chat_room_id", "user_id"} + ) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatParticipant { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "participant_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "joined_at", nullable = false) + private LocalDateTime joinedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "join_type", nullable = false, length = 10) + private JoinType joinType; + + @Column(name = "last_read_message_id") + private Long lastReadMessageId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + private ParticipantStatus status; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Builder + private ChatParticipant(ChatRoom chatRoom, User user, JoinType joinType) { + this.chatRoom = chatRoom; + this.user = user; + this.joinType = joinType != null ? joinType : JoinType.AUTO; + this.joinedAt = LocalDateTime.now(); + this.status = ParticipantStatus.ACTIVE; + this.updatedAt = LocalDateTime.now(); + } + + public static ChatParticipant create(User user, JoinType joinType) { + return ChatParticipant.builder() + .user(user) + .joinType(joinType) + .build(); + } + + void assignToChatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + } + + public void updateLastReadMessageId(Long messageId) { + this.lastReadMessageId = messageId; + this.updatedAt = LocalDateTime.now(); + } + + public void leave() { + this.status = ParticipantStatus.LEFT; + this.updatedAt = LocalDateTime.now(); + } + + public void kick() { + this.status = ParticipantStatus.KICKED; + this.updatedAt = LocalDateTime.now(); + } + + public void rejoin(JoinType joinType) { + this.status = ParticipantStatus.ACTIVE; + this.joinType = joinType; + this.joinedAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public boolean isActive() { + return status == ParticipantStatus.ACTIVE; + } + + public boolean isLeft() { + return status == ParticipantStatus.LEFT; + } + + public boolean isKicked() { + return status == ParticipantStatus.KICKED; + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatRoom.java b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatRoom.java new file mode 100644 index 0000000..7faa20c --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatRoom.java @@ -0,0 +1,99 @@ +package team.wego.wegobackend.chat.domain.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import team.wego.wegobackend.common.entity.BaseTimeEntity; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; + +@Entity +@Table(name = "chat_room") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatRoom extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "chat_room_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "chat_type", nullable = false, length = 10) + private ChatType chatType; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id") + private GroupV2 group; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true) + private List participants = new ArrayList<>(); + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true) + private List messages = new ArrayList<>(); + + @Builder + private ChatRoom(ChatType chatType, GroupV2 group, LocalDateTime expiresAt) { + this.chatType = chatType; + this.group = group; + this.expiresAt = expiresAt; + } + + public static ChatRoom createGroupChat(GroupV2 group) { + return ChatRoom.builder() + .chatType(ChatType.GROUP) + .group(group) + .build(); + } + + public static ChatRoom createDmChat() { + return ChatRoom.builder() + .chatType(ChatType.DM) + .build(); + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public boolean isExpired() { + return expiresAt != null && LocalDateTime.now().isAfter(expiresAt); + } + + public boolean isGroupChat() { + return chatType == ChatType.GROUP; + } + + public boolean isDmChat() { + return chatType == ChatType.DM; + } + + public void addParticipant(ChatParticipant participant) { + this.participants.add(participant); + participant.assignToChatRoom(this); + } + + public void addMessage(ChatMessage message) { + this.messages.add(message); + message.assignToChatRoom(this); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatType.java b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatType.java new file mode 100644 index 0000000..ea47add --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/entity/ChatType.java @@ -0,0 +1,6 @@ +package team.wego.wegobackend.chat.domain.entity; + +public enum ChatType { + GROUP, // 그룹 채팅 (모임 기반) + DM // 1:1 채팅 +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/entity/JoinType.java b/src/main/java/team/wego/wegobackend/chat/domain/entity/JoinType.java new file mode 100644 index 0000000..4702487 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/entity/JoinType.java @@ -0,0 +1,6 @@ +package team.wego.wegobackend.chat.domain.entity; + +public enum JoinType { + AUTO, // 모임 참여 시 자동 입장 + MANUAL // 수동 입장 +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/entity/MessageType.java b/src/main/java/team/wego/wegobackend/chat/domain/entity/MessageType.java new file mode 100644 index 0000000..d8c6154 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/entity/MessageType.java @@ -0,0 +1,6 @@ +package team.wego.wegobackend.chat.domain.entity; + +public enum MessageType { + TEXT, // 일반 텍스트 메시지 + SYSTEM // 시스템 메시지 (입장/퇴장 등) +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/entity/ParticipantStatus.java b/src/main/java/team/wego/wegobackend/chat/domain/entity/ParticipantStatus.java new file mode 100644 index 0000000..a752dcb --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/entity/ParticipantStatus.java @@ -0,0 +1,7 @@ +package team.wego.wegobackend.chat.domain.entity; + +public enum ParticipantStatus { + ACTIVE, // 활성 상태 + LEFT, // 퇴장 + KICKED // 추방됨 +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/exception/ChatErrorCode.java b/src/main/java/team/wego/wegobackend/chat/domain/exception/ChatErrorCode.java new file mode 100644 index 0000000..8818302 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/exception/ChatErrorCode.java @@ -0,0 +1,34 @@ +package team.wego.wegobackend.chat.domain.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import team.wego.wegobackend.common.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum ChatErrorCode implements ErrorCode { + + // 채팅방 관련 + CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다"), + CHAT_ROOM_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 채팅방입니다"), + CHAT_ROOM_EXPIRED(HttpStatus.GONE, "만료된 채팅방입니다"), + + // 참여자 관련 + NOT_CHAT_PARTICIPANT(HttpStatus.FORBIDDEN, "채팅방에 참여하지 않았습니다"), + ALREADY_CHAT_PARTICIPANT(HttpStatus.CONFLICT, "이미 참여 중인 채팅방입니다"), + NOT_CHAT_ROOM_OWNER(HttpStatus.FORBIDDEN, "방장만 사용할 수 있는 기능입니다"), + CANNOT_KICK_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 추방할 수 없습니다"), + PARTICIPANT_KICKED(HttpStatus.FORBIDDEN, "채팅방에서 추방되었습니다"), + + // 메시지 관련 + MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "메시지가 너무 깁니다 (최대 %d자)"), + MESSAGE_EMPTY(HttpStatus.BAD_REQUEST, "메시지 내용을 입력해주세요"), + + // DM 관련 + CANNOT_DM_SELF(HttpStatus.BAD_REQUEST, "자기 자신에게 메시지를 보낼 수 없습니다"), + DM_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 1:1 채팅방입니다"); + + private final HttpStatus httpStatus; + private final String messageTemplate; +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/exception/ChatException.java b/src/main/java/team/wego/wegobackend/chat/domain/exception/ChatException.java new file mode 100644 index 0000000..43f1a74 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/exception/ChatException.java @@ -0,0 +1,15 @@ +package team.wego.wegobackend.chat.domain.exception; + +import team.wego.wegobackend.common.exception.AppException; +import team.wego.wegobackend.common.exception.ErrorCode; + +public class ChatException extends AppException { + + public ChatException(ErrorCode errorCode, Object... args) { + super(errorCode, args); + } + + public ChatException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatMessageRepository.java b/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatMessageRepository.java new file mode 100644 index 0000000..5725841 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatMessageRepository.java @@ -0,0 +1,50 @@ +package team.wego.wegobackend.chat.domain.repository; + +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import team.wego.wegobackend.chat.domain.entity.ChatMessage; + +public interface ChatMessageRepository extends JpaRepository { + + @Query(""" + SELECT cm FROM ChatMessage cm + WHERE cm.chatRoom.id = :chatRoomId AND cm.id < :cursor + ORDER BY cm.createdAt DESC + """) + Slice findByChatRoomIdAndIdLessThan( + @Param("chatRoomId") Long chatRoomId, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + SELECT cm FROM ChatMessage cm + WHERE cm.chatRoom.id = :chatRoomId + ORDER BY cm.createdAt DESC + """) + Slice findByChatRoomIdOrderByCreatedAtDesc( + @Param("chatRoomId") Long chatRoomId, + Pageable pageable + ); + + @Query(""" + SELECT cm FROM ChatMessage cm + WHERE cm.chatRoom.id = :chatRoomId + ORDER BY cm.createdAt DESC + LIMIT 1 + """) + Optional findLatestByChatRoomId(@Param("chatRoomId") Long chatRoomId); + + @Query(""" + SELECT COUNT(cm) FROM ChatMessage cm + WHERE cm.chatRoom.id = :chatRoomId AND cm.id > :lastReadMessageId + """) + int countUnreadMessages( + @Param("chatRoomId") Long chatRoomId, + @Param("lastReadMessageId") Long lastReadMessageId + ); +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatParticipantRepository.java b/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatParticipantRepository.java new file mode 100644 index 0000000..a092e86 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatParticipantRepository.java @@ -0,0 +1,30 @@ +package team.wego.wegobackend.chat.domain.repository; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import team.wego.wegobackend.chat.domain.entity.ChatParticipant; +import team.wego.wegobackend.chat.domain.entity.ParticipantStatus; + +public interface ChatParticipantRepository extends JpaRepository { + + Optional findByChatRoomIdAndUserId(Long chatRoomId, Long userId); + + List findAllByChatRoomIdAndStatus(Long chatRoomId, ParticipantStatus status); + + @Query(""" + SELECT cp FROM ChatParticipant cp + WHERE cp.chatRoom.id = :chatRoomId AND cp.status = 'ACTIVE' + """) + List findActiveParticipants(@Param("chatRoomId") Long chatRoomId); + + @Query(""" + SELECT COUNT(cp) FROM ChatParticipant cp + WHERE cp.chatRoom.id = :chatRoomId AND cp.status = 'ACTIVE' + """) + int countActiveParticipants(@Param("chatRoomId") Long chatRoomId); + + boolean existsByChatRoomIdAndUserIdAndStatus(Long chatRoomId, Long userId, ParticipantStatus status); +} diff --git a/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatRoomRepository.java b/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatRoomRepository.java new file mode 100644 index 0000000..2f499af --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/domain/repository/ChatRoomRepository.java @@ -0,0 +1,42 @@ +package team.wego.wegobackend.chat.domain.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import team.wego.wegobackend.chat.domain.entity.ChatRoom; +import team.wego.wegobackend.chat.domain.entity.ChatType; + +public interface ChatRoomRepository extends JpaRepository { + + Optional findByGroupId(Long groupId); + + @Query("SELECT cr FROM ChatRoom cr WHERE cr.chatType = :chatType AND cr.expiresAt < :now") + List findExpiredChatRooms( + @Param("chatType") ChatType chatType, + @Param("now") LocalDateTime now + ); + + @Query(""" + SELECT cr FROM ChatRoom cr + JOIN cr.participants cp + WHERE cp.user.id = :userId AND cp.status = 'ACTIVE' + ORDER BY cr.updatedAt DESC + """) + List findAllByUserIdAndActiveStatus(@Param("userId") Long userId); + + @Query(""" + SELECT cr FROM ChatRoom cr + JOIN cr.participants cp1 + JOIN cr.participants cp2 + WHERE cr.chatType = 'DM' + AND cp1.user.id = :userId1 AND cp1.status = 'ACTIVE' + AND cp2.user.id = :userId2 AND cp2.status = 'ACTIVE' + """) + Optional findDmChatRoom( + @Param("userId1") Long userId1, + @Param("userId2") Long userId2 + ); +} diff --git a/src/main/java/team/wego/wegobackend/chat/presentation/ChatMessageController.java b/src/main/java/team/wego/wegobackend/chat/presentation/ChatMessageController.java new file mode 100644 index 0000000..bff01a6 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/presentation/ChatMessageController.java @@ -0,0 +1,91 @@ +package team.wego.wegobackend.chat.presentation; + +import java.security.Principal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Controller; +import team.wego.wegobackend.chat.application.dto.request.SendMessageRequest; +import team.wego.wegobackend.chat.application.dto.response.ChatMessagePayload; +import team.wego.wegobackend.chat.application.service.ChatMessageService; +import team.wego.wegobackend.chat.config.StompChannelInterceptor.StompPrincipal; + +@Controller +@RequiredArgsConstructor +@Slf4j +public class ChatMessageController { + + private final SimpMessageSendingOperations messagingTemplate; + private final ChatMessageService chatMessageService; + + /** + * 메시지 전송 처리 + * 클라이언트에서 /pub/chat/message 로 전송 + * 구독자들은 /sub/chat/room/{roomId} 로 수신 + */ + @MessageMapping("/chat/message") + public void sendMessage(@Payload SendMessageRequest request, Principal principal) { + if (principal == null) { + log.warn("인증되지 않은 사용자의 메시지 전송 시도"); + return; + } + + StompPrincipal stompPrincipal = (StompPrincipal) principal; + Long userId = stompPrincipal.userId(); + + log.debug("메시지 수신 - roomId: {}, userId: {}, content: {}", + request.chatRoomId(), userId, request.content()); + + ChatMessagePayload payload = chatMessageService.sendMessage( + userId, request.chatRoomId(), request.content() + ); + + // 채팅방 구독자들에게 메시지 브로드캐스트 + messagingTemplate.convertAndSend( + "/sub/chat/room/" + request.chatRoomId(), + payload + ); + } + + /** + * 입장 메시지 전송 (시스템 메시지) + */ + public void sendEnterMessage(Long chatRoomId, String userName) { + ChatMessagePayload payload = ChatMessagePayload.systemMessage( + chatRoomId, + userName + "님이 입장했습니다" + ); + + messagingTemplate.convertAndSend( + "/sub/chat/room/" + chatRoomId, + payload + ); + } + + /** + * 퇴장 메시지 전송 (시스템 메시지) + */ + public void sendLeaveMessage(Long chatRoomId, String userName) { + ChatMessagePayload payload = ChatMessagePayload.systemMessage( + chatRoomId, + userName + "님이 퇴장했습니다" + ); + + messagingTemplate.convertAndSend( + "/sub/chat/room/" + chatRoomId, + payload + ); + } + + /** + * 특정 사용자에게 개인 알림 전송 + */ + public void sendToUser(Long userId, Object payload) { + messagingTemplate.convertAndSend( + "/sub/user/" + userId, + payload + ); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomController.java b/src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomController.java new file mode 100644 index 0000000..753ebd5 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomController.java @@ -0,0 +1,140 @@ +package team.wego.wegobackend.chat.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.PutMapping; +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 team.wego.wegobackend.chat.application.dto.request.CreateDmRequest; +import team.wego.wegobackend.chat.application.dto.request.KickParticipantRequest; +import team.wego.wegobackend.chat.application.dto.response.ChatRoomListResponse; +import team.wego.wegobackend.chat.application.dto.response.ChatRoomResponse; +import team.wego.wegobackend.chat.application.dto.response.MessageListResponse; +import team.wego.wegobackend.chat.application.dto.response.ParticipantListResponse; +import team.wego.wegobackend.chat.application.dto.response.ReadStatusResponse; +import team.wego.wegobackend.chat.application.service.ChatMessageService; +import team.wego.wegobackend.chat.application.service.ChatRoomService; +import team.wego.wegobackend.common.response.ApiResponse; +import team.wego.wegobackend.common.security.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/chat") +@RequiredArgsConstructor +public class ChatRoomController implements ChatRoomControllerDocs { + + private final ChatRoomService chatRoomService; + private final ChatMessageService chatMessageService; + + /** + * 내 채팅방 목록 조회 + */ + @GetMapping("/rooms") + public ResponseEntity> getMyChatRooms( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + ChatRoomListResponse response = chatRoomService.getMyChatRooms(userDetails.getId()); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response)); + } + + /** + * 채팅방 상세 조회 + */ + @GetMapping("/rooms/{roomId}") + public ResponseEntity> getChatRoom( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long roomId + ) { + ChatRoomResponse response = chatRoomService.getChatRoom(userDetails.getId(), roomId); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response)); + } + + /** + * 메시지 이력 조회 (커서 기반 페이징) + */ + @GetMapping("/rooms/{roomId}/messages") + public ResponseEntity> getMessages( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long roomId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "50") int size + ) { + MessageListResponse response = chatMessageService.getMessages( + userDetails.getId(), roomId, cursor, size + ); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response)); + } + + /** + * 읽음 처리 + */ + @PutMapping("/rooms/{roomId}/read") + public ResponseEntity> markAsRead( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long roomId + ) { + ReadStatusResponse response = chatRoomService.markAsRead(userDetails.getId(), roomId); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response)); + } + + /** + * 참여자 추방 (방장 전용) + */ + @PostMapping("/rooms/{roomId}/kick") + public ResponseEntity> kickParticipant( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long roomId, + @RequestBody @Valid KickParticipantRequest request + ) { + chatRoomService.kickParticipant(userDetails.getId(), roomId, request.targetUserId()); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), null)); + } + + /** + * 채팅방 나가기 + */ + @PostMapping("/rooms/{roomId}/leave") + public ResponseEntity> leaveChatRoom( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long roomId + ) { + chatRoomService.leaveChatRoom(userDetails.getId(), roomId); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), null)); + } + + /** + * 1:1 채팅방 생성 또는 조회 + */ + @PostMapping("/dm") + public ResponseEntity> createOrGetDmRoom( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid CreateDmRequest request + ) { + ChatRoomResponse response = chatRoomService.createOrGetDmRoom( + userDetails.getId(), request.targetUserId() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(HttpStatus.CREATED.value(), response)); + } + + /** + * 채팅방 참여자 목록 조회 + */ + @GetMapping("/rooms/{roomId}/participants") + public ResponseEntity> getParticipants( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long roomId + ) { + ParticipantListResponse response = chatRoomService.getParticipants( + userDetails.getId(), roomId + ); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response)); + } +} diff --git a/src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomControllerDocs.java b/src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomControllerDocs.java new file mode 100644 index 0000000..0793dfa --- /dev/null +++ b/src/main/java/team/wego/wegobackend/chat/presentation/ChatRoomControllerDocs.java @@ -0,0 +1,189 @@ +package team.wego.wegobackend.chat.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import team.wego.wegobackend.chat.application.dto.request.CreateDmRequest; +import team.wego.wegobackend.chat.application.dto.request.KickParticipantRequest; +import team.wego.wegobackend.chat.application.dto.response.ChatRoomListResponse; +import team.wego.wegobackend.chat.application.dto.response.ChatRoomResponse; +import team.wego.wegobackend.chat.application.dto.response.MessageListResponse; +import team.wego.wegobackend.chat.application.dto.response.ParticipantListResponse; +import team.wego.wegobackend.chat.application.dto.response.ReadStatusResponse; +import team.wego.wegobackend.common.response.ApiResponse; +import team.wego.wegobackend.common.security.CustomUserDetails; + +@Tag(name = "채팅 API", description = "채팅방 및 메시지 관련 API") +public interface ChatRoomControllerDocs { + + @Operation( + summary = "내 채팅방 목록 조회", + description = "현재 로그인한 사용자가 참여 중인 모든 채팅방 목록을 조회합니다. " + + "각 채팅방의 마지막 메시지, 안읽은 메시지 수 등을 포함합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공" + ) + }) + ResponseEntity> getMyChatRooms( + @AuthenticationPrincipal CustomUserDetails userDetails + ); + + @Operation( + summary = "채팅방 상세 조회", + description = "특정 채팅방의 상세 정보와 참여자 목록을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "채팅방에 참여하지 않은 사용자" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "채팅방을 찾을 수 없음" + ) + }) + ResponseEntity> getChatRoom( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "채팅방 ID", required = true) @PathVariable Long roomId + ); + + @Operation( + summary = "메시지 이력 조회", + description = "채팅방의 메시지 이력을 커서 기반 페이징으로 조회합니다. " + + "cursor가 없으면 최신 메시지부터 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "채팅방에 참여하지 않은 사용자" + ) + }) + ResponseEntity> getMessages( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "채팅방 ID", required = true) @PathVariable Long roomId, + @Parameter(description = "페이징 커서 (이전 응답의 nextCursor 값)") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회할 메시지 수 (기본값: 50)") @RequestParam(defaultValue = "50") int size + ); + + @Operation( + summary = "읽음 처리", + description = "채팅방의 모든 메시지를 읽음 처리합니다. " + + "마지막으로 읽은 메시지 ID가 업데이트됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "읽음 처리 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "채팅방에 참여하지 않은 사용자" + ) + }) + ResponseEntity> markAsRead( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "채팅방 ID", required = true) @PathVariable Long roomId + ); + + @Operation( + summary = "참여자 추방", + description = "채팅방에서 특정 참여자를 추방합니다. 그룹 채팅방의 경우 방장만 사용할 수 있습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "추방 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "자기 자신을 추방할 수 없음" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "방장만 사용할 수 있는 기능" + ) + }) + ResponseEntity> kickParticipant( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "채팅방 ID", required = true) @PathVariable Long roomId, + @Valid @RequestBody KickParticipantRequest request + ); + + @Operation( + summary = "채팅방 나가기", + description = "채팅방에서 퇴장합니다. 퇴장 후에는 해당 채팅방에 접근할 수 없습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "퇴장 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "채팅방에 참여하지 않은 사용자" + ) + }) + ResponseEntity> leaveChatRoom( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "채팅방 ID", required = true) @PathVariable Long roomId + ); + + @Operation( + summary = "1:1 채팅방 생성/조회", + description = "특정 사용자와의 1:1 채팅방을 생성하거나 기존 채팅방을 조회합니다. " + + "이미 해당 사용자와의 DM 채팅방이 있으면 기존 채팅방을 반환합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "채팅방 생성/조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "자기 자신에게 메시지를 보낼 수 없음" + ) + }) + ResponseEntity> createOrGetDmRoom( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody CreateDmRequest request + ); + + @Operation( + summary = "채팅방 참여자 목록 조회", + description = "채팅방의 활성 참여자 목록을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "채팅방에 참여하지 않은 사용자" + ) + }) + ResponseEntity> getParticipants( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "채팅방 ID", required = true) @PathVariable Long roomId + ); +} diff --git a/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java b/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java index a36e627..a36405c 100644 --- a/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java +++ b/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java @@ -15,6 +15,9 @@ public class SecurityEndpoints { "/swagger-ui.html", "/api-docs/**", "/v*/api-docs/**", + + // WebSocket (인증은 STOMP CONNECT에서 처리) + "/ws-chat/**", }; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 825ad7e..f0552ed 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -87,4 +87,24 @@ jwt: expiration: 3600000 refresh: token: - expiration: 604800000 \ No newline at end of file + expiration: 604800000 + +# 채팅 설정 +chat: + auto-join: + enabled: true # 모임 참여 시 채팅방 자동 참여 + dm: + delete-policy: NONE # NONE | INACTIVE_DAYS + inactive-days: 90 + websocket: + endpoint: /ws-chat + allowed-origins: + - http://localhost:3000 + - http://localhost:8080 + - https://wego.monster + - https://api.wego.monster + message: + max-length: 1000 + batch: + delete-expired-chat: + cron: "0 0 * * * *" # 매 시간 정각 \ No newline at end of file diff --git a/src/test/http/auth/auth-api.http b/src/test/http/auth/auth-api.http index 51381f2..a3f3cec 100644 --- a/src/test/http/auth/auth-api.http +++ b/src/test/http/auth/auth-api.http @@ -3,9 +3,9 @@ POST http://localhost:8080/api/v1/auth/signup Content-Type: application/json { - "email": "test@example.com", + "email": "ttest@test.com", "password": "Test1234!@#", - "nickName": "Beemo" + "nickName": "Beemoo" } ### 로그인