Skip to content

Commit a98eb89

Browse files
authored
Merge branch 'develop' into feat/kakaoLogin/main
2 parents 5e5070f + a95bfe2 commit a98eb89

File tree

19 files changed

+363
-12
lines changed

19 files changed

+363
-12
lines changed

src/main/java/org/dfbf/soundlink/domain/blocklist/entity/Blocklist.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ public class Blocklist {
2121
@Column(name = "blocklist_id")
2222
private Long blocklistId;
2323

24-
@ManyToOne
24+
@ManyToOne(fetch = FetchType.LAZY)
2525
@JoinColumn(name = "user_id")
2626
private User user;
2727

28-
@ManyToOne
28+
@ManyToOne(fetch = FetchType.LAZY)
2929
@JoinColumn(name = "blocked_user_id")
3030
private User blockedUser;
3131

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package org.dfbf.soundlink.domain.chat.controller;
2+
3+
import io.lettuce.core.dynamic.annotation.Param;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import lombok.RequiredArgsConstructor;
7+
import org.dfbf.soundlink.domain.chat.service.ChatRoomService;
8+
import org.dfbf.soundlink.global.exception.ResponseResult;
9+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10+
import org.springframework.web.bind.annotation.*;
11+
12+
@RestController
13+
@RequestMapping("/api/chat")
14+
@RequiredArgsConstructor
15+
@Tag(name = "Chat API", description = "채팅 관련 API")
16+
public class ChatController {
17+
18+
private final ChatRoomService chatRoomService;
19+
20+
@PostMapping("/request")
21+
@Operation(summary = "채팅 요청 API", description = "채팅 요청 (상세 정보는 노션 API 명세 확인)")
22+
public ResponseResult requestChat(@AuthenticationPrincipal Long id, @RequestBody Long emotionRecordId) {
23+
return chatRoomService.saveRequestToRedis(id, emotionRecordId);
24+
}
25+
26+
@DeleteMapping("/request")
27+
@Operation(summary = "채팅 요청 취소 API", description = "채팅 요청 취소")
28+
public ResponseResult cancelChatRequest(@AuthenticationPrincipal Long id, @RequestBody Long emotionRecordId) {
29+
return chatRoomService.deleteRequestFromRedis(id, emotionRecordId);
30+
}
31+
32+
@PostMapping("/create")
33+
@Operation(summary = "채팅요청 시 채팅방 생성", description = "requestId, responseId 값 확인")
34+
public ResponseResult create(@AuthenticationPrincipal Long userId, @RequestParam Long recordId) {
35+
return chatRoomService.createChatRoom(userId, recordId);
36+
}
37+
38+
@PostMapping("/close")
39+
@Operation(summary = "채팅방 닫기" , description="닫을 시 상태값 'close'변경, 레디스에서 삭제")
40+
public ResponseResult close(@AuthenticationPrincipal Long userId, Long chatRoomId) {
41+
return chatRoomService.closeChatRoom(userId, chatRoomId);
42+
}
43+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package org.dfbf.soundlink.domain.chat.dto;
2+
3+
4+
public record ChatReqDto (
5+
Long requestId,
6+
Long responseId
7+
){}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package org.dfbf.soundlink.domain.chat.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AccessLevel;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
import org.dfbf.soundlink.domain.emotionRecord.entity.EmotionRecord;
9+
import org.dfbf.soundlink.domain.user.entity.User;
10+
import org.dfbf.soundlink.global.comm.enums.RoomStatus;
11+
import org.hibernate.annotations.CreationTimestamp;
12+
import org.hibernate.annotations.UpdateTimestamp;
13+
14+
import java.sql.Timestamp;
15+
16+
@Entity
17+
@Getter
18+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
19+
public class ChatRoom {
20+
@Id
21+
@GeneratedValue(strategy = GenerationType.IDENTITY)
22+
@Column(name = "chat_room_id")
23+
private Long chatRoomId;
24+
25+
@Column(name = "start_time")
26+
private Timestamp startTime;
27+
28+
@Column(name = "end_time")
29+
private Timestamp endTime;
30+
31+
@ManyToOne(fetch = FetchType.LAZY)
32+
@JoinColumn (name = "request_user_id")
33+
private User requestUserId;
34+
35+
@ManyToOne(fetch = FetchType.LAZY)
36+
@JoinColumn(name = "record_id")
37+
private EmotionRecord recordId;
38+
39+
@Enumerated(EnumType.STRING)
40+
@Column(name = "status")
41+
private RoomStatus status;
42+
43+
@CreationTimestamp
44+
@Column(name = "created_at")
45+
private Timestamp createdAt;
46+
47+
@UpdateTimestamp
48+
@Column(name = "updated_at")
49+
private Timestamp updatedAt;
50+
51+
@Builder
52+
public ChatRoom(User requestUserId, EmotionRecord recordId, RoomStatus status,
53+
Timestamp startTime, Timestamp endTime) {
54+
this.requestUserId = requestUserId;
55+
this.recordId = recordId;
56+
this.status = status;
57+
this.startTime = startTime;
58+
this.endTime = endTime;
59+
}
60+
61+
//채팅방 상태 업데이트
62+
public void updateChatRoomStatus(RoomStatus status){
63+
this.status = status;
64+
if(status == RoomStatus.CLOSED){
65+
this.endTime = new Timestamp(System.currentTimeMillis());
66+
}
67+
}
68+
69+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.dfbf.soundlink.domain.chat.entity.redis;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Data;
5+
6+
import java.io.Serializable;
7+
8+
@Data
9+
@AllArgsConstructor
10+
public class ChatRequest implements Serializable {
11+
Long requestId;
12+
Long responseId;
13+
Long emotionRecordId;
14+
}
15+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package org.dfbf.soundlink.domain.chat.exception;
2+
3+
import org.dfbf.soundlink.global.exception.BusinessException;
4+
import org.dfbf.soundlink.global.exception.ErrorCode;
5+
6+
public class ChatRoomNotFoundException extends BusinessException {
7+
public ChatRoomNotFoundException() {super(ErrorCode.CHATROOM_NOT_FOUND); }
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.dfbf.soundlink.domain.chat.exception;
2+
3+
import org.dfbf.soundlink.global.exception.BusinessException;
4+
import org.dfbf.soundlink.global.exception.ErrorCode;
5+
6+
public class UnauthorizedAccessException extends BusinessException {
7+
public UnauthorizedAccessException() {
8+
super(ErrorCode.CHAT_UNAUTHORIZED);
9+
}
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.dfbf.soundlink.domain.chat.repository;
2+
3+
import org.dfbf.soundlink.domain.chat.entity.ChatRoom;
4+
import org.dfbf.soundlink.domain.emotionRecord.entity.EmotionRecord;
5+
import org.dfbf.soundlink.domain.user.entity.User;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
8+
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
9+
boolean existsByRequestUserIdAndRecordId(User requestUserId, EmotionRecord recordId);
10+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package org.dfbf.soundlink.domain.chat.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.dfbf.soundlink.domain.chat.entity.redis.ChatRequest;
5+
import org.dfbf.soundlink.domain.chat.dto.ChatReqDto;
6+
import org.dfbf.soundlink.domain.chat.entity.ChatRoom;
7+
import org.dfbf.soundlink.domain.chat.exception.ChatRoomNotFoundException;
8+
import org.dfbf.soundlink.domain.chat.exception.UnauthorizedAccessException;
9+
import org.dfbf.soundlink.domain.chat.repository.ChatRoomRepository;
10+
import org.dfbf.soundlink.domain.emotionRecord.entity.EmotionRecord;
11+
import org.dfbf.soundlink.domain.emotionRecord.exception.EmotionRecordNotFoundException;
12+
import org.dfbf.soundlink.domain.emotionRecord.exception.UserNotFoundException;
13+
import org.dfbf.soundlink.domain.emotionRecord.repository.EmotionRecordRepository;
14+
import org.dfbf.soundlink.domain.user.entity.User;
15+
import org.dfbf.soundlink.domain.user.repository.UserRepository;
16+
import org.dfbf.soundlink.global.comm.enums.RoomStatus;
17+
import org.dfbf.soundlink.global.exception.ErrorCode;
18+
import org.dfbf.soundlink.global.exception.ResponseResult;
19+
import org.springframework.dao.DataIntegrityViolationException;
20+
import org.springframework.data.redis.core.RedisTemplate;
21+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
22+
import org.springframework.stereotype.Service;
23+
import org.springframework.transaction.annotation.Transactional;
24+
25+
import java.time.Duration;
26+
import java.sql.Timestamp;
27+
28+
@Service
29+
@RequiredArgsConstructor
30+
public class ChatRoomService {
31+
32+
private final RedisTemplate<String, Object> redisTemplate;
33+
private final EmotionRecordRepository emotionRecordRepository;
34+
private final ChatRoomRepository chatRoomRepository;
35+
private final UserRepository userRepository;
36+
37+
private static final String CHAT_REQUEST_KEY = "chatRequest";
38+
39+
// 요청을 Redis에 저장 (TTL: 60초)
40+
public ResponseResult saveRequestToRedis(Long requestUserId, Long emotionRecordId) {
41+
try {
42+
// 응답자의 ID를 EmotionRecord에서 가져옴
43+
Long responseUserId = emotionRecordRepository.findById(emotionRecordId)
44+
.orElseThrow(EmotionRecordNotFoundException::new)
45+
.getUser()
46+
.getUserId();
47+
48+
// 요청자와 응답자가 같은 경우
49+
if (requestUserId.equals(responseUserId)) {
50+
return new ResponseResult(400, "You can't chat with yourself.");
51+
}
52+
53+
// Redis에 이미 requestUserId가 포함되어 있는 경우
54+
if (!redisTemplate.keys(CHAT_REQUEST_KEY + requestUserId + "to*").isEmpty()) {
55+
String firstKey = redisTemplate.keys(CHAT_REQUEST_KEY + requestUserId + "to*").iterator().next(); // 첫 번째 키 가져오기
56+
Long ttl = redisTemplate.getExpire(firstKey);
57+
return new ResponseResult(400, ttl + "초 후에 다시 시도해주세요.");
58+
}
59+
60+
// Key & Request 객체 생성
61+
String key = CHAT_REQUEST_KEY + requestUserId + "to" + emotionRecordId;
62+
ChatRequest chatRequest = new ChatRequest(requestUserId, responseUserId, emotionRecordId);
63+
64+
// Redis 저장
65+
redisTemplate.opsForValue().set(key, chatRequest, Duration.ofSeconds(61));
66+
67+
return new ResponseResult(ErrorCode.SUCCESS);
68+
} catch (EmotionRecordNotFoundException e) {
69+
return new ResponseResult(ErrorCode.FAIL_TO_FIND_EMOTION_RECORD);
70+
} catch (Exception e) {
71+
return new ResponseResult(400, "Chat request failed.");
72+
}
73+
}
74+
75+
// 요청을 삭제
76+
public ResponseResult deleteRequestFromRedis(Long requestUserId, Long emotionRecordId) {
77+
try {
78+
// Key 생성
79+
String key = CHAT_REQUEST_KEY + requestUserId + "to" + emotionRecordId;
80+
81+
// Redis에 Key가 존재하는 경우 삭제 (KEY가 없는 경우 400)
82+
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
83+
redisTemplate.delete(key);
84+
return new ResponseResult(ErrorCode.SUCCESS);
85+
} else {
86+
return new ResponseResult(400, "ChatRequest not found or expired.");
87+
}
88+
89+
} catch (EmotionRecordNotFoundException e) {
90+
return new ResponseResult(ErrorCode.FAIL_TO_FIND_EMOTION_RECORD);
91+
} catch (Exception e) {
92+
return new ResponseResult(400, "Chat request failed.");
93+
}
94+
}
95+
96+
@Transactional
97+
public ResponseResult createChatRoom(Long userId, Long recordId) {
98+
try {
99+
// 요청 보내는사람
100+
User requestUserId = userRepository.findById(userId)
101+
.orElseThrow(UserNotFoundException::new);
102+
103+
// 감정기록 조회
104+
EmotionRecord emotionRecord = emotionRecordRepository.findById(recordId)
105+
.orElseThrow(EmotionRecordNotFoundException::new);
106+
107+
// 이미 존재하는 채팅방인지 확인
108+
if(chatRoomRepository.existsByRequestUserIdAndRecordId(requestUserId,emotionRecord)){
109+
return new ResponseResult(ErrorCode.CHAT_FAILED, "이미 존재하는 채팅방입니다.");
110+
}
111+
112+
Long responseUserId = emotionRecord.getUser().getUserId();
113+
114+
ChatRoom chatRoom = ChatRoom.builder()
115+
.requestUserId(requestUserId)
116+
.recordId(emotionRecord)
117+
.status(RoomStatus.WAITING) //상태 : 대기
118+
.startTime(new Timestamp(System.currentTimeMillis()))
119+
.endTime(null)
120+
.build();
121+
122+
// DB에 저장
123+
chatRoomRepository.save(chatRoom);
124+
125+
ChatReqDto chatReqDto = new ChatReqDto(userId, responseUserId);
126+
127+
// 레디스에 저장
128+
redisTemplate.opsForValue().set("Room::"+chatRoom.getChatRoomId(), String.valueOf(chatReqDto));
129+
130+
return new ResponseResult(ErrorCode.SUCCESS, chatRoom);
131+
} catch (Exception e) {
132+
return new ResponseResult(ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage());
133+
}
134+
}
135+
136+
// 채팅방 닫기
137+
@Transactional
138+
public ResponseResult closeChatRoom(@AuthenticationPrincipal Long userId, Long chatRoomId) {
139+
try {
140+
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
141+
.orElseThrow(ChatRoomNotFoundException::new);
142+
143+
// 요청자 또는 응답자가 아니면 예외 처리
144+
if(!chatRoom.getRequestUserId().getUserId().equals(userId) &&
145+
!chatRoom.getRecordId().getUser().getUserId().equals(userId)) {
146+
throw new UnauthorizedAccessException(); // 권한이 없을 경우 예외 발생
147+
}
148+
149+
chatRoom.updateChatRoomStatus(RoomStatus.CLOSED); // 삳태 '닫기'로 변경
150+
chatRoomRepository.save(chatRoom); // DB에 저장
151+
152+
redisTemplate.delete("Room::"+chatRoomId); // 레디스에서 삭제
153+
return new ResponseResult(ErrorCode.SUCCESS);
154+
} catch (Exception e) {
155+
return new ResponseResult(ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage());
156+
}
157+
}
158+
}

src/main/java/org/dfbf/soundlink/domain/emotionRecord/controller/EmotionRecordController.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ public class EmotionRecordController {
2727
summary = "감정 기록 작성/저장 API",
2828
description = "작성한 감정 기록을 저장합니다."
2929
)
30-
3130
public ResponseResult saveEmotionWithMusic(
3231
@AuthenticationPrincipal Long userId,
3332
@Valid @RequestBody EmotionRecordRequestDTO request) {

0 commit comments

Comments
 (0)