diff --git a/build.gradle b/build.gradle index 4b6f2dd..ca320af 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,8 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'software.amazon.awssdk:s3:2.20.7' + } tasks.named('test') { diff --git a/src/main/java/com/leets/X/domain/chat/controller/ChatRoomController.java b/src/main/java/com/leets/X/domain/chat/controller/ChatRoomController.java new file mode 100644 index 0000000..fc011e6 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/controller/ChatRoomController.java @@ -0,0 +1,45 @@ +package com.leets.X.domain.chat.controller; + +import com.leets.X.domain.chat.dto.request.ChatRoomCheckRequstDto; +import com.leets.X.domain.chat.dto.request.FindChatRoomRequestDto; +import com.leets.X.domain.chat.dto.response.ChatRoomResponseDto; +import com.leets.X.domain.chat.service.ChatRoomService; +import com.leets.X.global.common.response.ResponseDto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import static com.leets.X.domain.chat.controller.ResponseMessage.*; + +@Slf4j +@RestController +@RequestMapping("/api/v1/chatRoom") +@RequiredArgsConstructor +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + + @PostMapping + public ResponseDto createChatRoom(@RequestBody @Valid FindChatRoomRequestDto findChatRoomRequestDto){ + ChatRoomResponseDto response = chatRoomService.saveChatRoom(findChatRoomRequestDto); + return ResponseDto.response(CHATROOM_CREATE_SUCCESS.getCode(), CHATROOM_CREATE_SUCCESS.getMessage(), response); + } + + + + // user1Id와 user2Id의 채팅방이 있는 지 조회 + @GetMapping("/{user1Id}/{user2Id}") + public ResponseDto existChatRoom(@PathVariable Long user1Id, @PathVariable Long user2Id){ + ChatRoomResponseDto response = chatRoomService.findUser1User2ChatRoom(user1Id , user2Id); + + return ResponseDto.response(GET_ROOMID.getCode(), GET_ROOMID.getMessage(), response); + } + + @PostMapping("/{roomId}") // addListener 테스트 용 + public void addListener(@PathVariable @NotNull Long roomId) { + chatRoomService.addListener(roomId); + log.info(roomId+":addListener"); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/controller/ChattingController.java b/src/main/java/com/leets/X/domain/chat/controller/ChattingController.java new file mode 100644 index 0000000..6132be4 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/controller/ChattingController.java @@ -0,0 +1,39 @@ +package com.leets.X.domain.chat.controller; + +import com.leets.X.domain.chat.dto.request.GetChatRoomRequestDto; +import com.leets.X.domain.chat.dto.response.ChattingDto; +import com.leets.X.domain.chat.dto.response.ChattingListResponseDto; +import com.leets.X.domain.chat.service.ChattingService; +import com.leets.X.global.common.response.ResponseDto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.leets.X.domain.chat.controller.ResponseMessage.GET_CHATROOM; +import static com.leets.X.domain.chat.controller.ResponseMessage.GET_CHATTING_LIST; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class ChattingController { + + private final ChattingService chattingService; + + // 채팅방 하나를 조회해준다. (대화 내역을 돌려준다는 의미) + @GetMapping("/chatting/{roomId}/{page}/{size}") + public ResponseDto findChatting(@PathVariable Long roomId, @PathVariable Integer page, @PathVariable Integer size) { + ChattingDto response = chattingService.getChatRoom(roomId, page, size); + return ResponseDto.response(GET_CHATROOM.getCode(), GET_CHATROOM.getMessage(), response); + } + + + @GetMapping("/chattingList/{userId}") + public ResponseDto> findChattingList(@PathVariable Long userId){ + List response = chattingService.getChattingList(userId); + return ResponseDto.response(GET_CHATTING_LIST.getCode(), GET_CHATTING_LIST.getMessage(), response); + } + +} diff --git a/src/main/java/com/leets/X/domain/chat/controller/PublishMessageController.java b/src/main/java/com/leets/X/domain/chat/controller/PublishMessageController.java new file mode 100644 index 0000000..be5ecb6 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/controller/PublishMessageController.java @@ -0,0 +1,23 @@ +package com.leets.X.domain.chat.controller; + + +import com.leets.X.domain.chat.dto.request.MessageDto; +import com.leets.X.domain.chat.service.PublishMessageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +@RequiredArgsConstructor +public class PublishMessageController { + + private final PublishMessageService publishMessageService; + + // 클라이언트는 "/pub/chats/messages" 로 보낸다. + @MessageMapping("/chats/messages") + public void message(MessageDto messageDto) { + publishMessageService.publishMessage(messageDto); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/controller/ResponseMessage.java b/src/main/java/com/leets/X/domain/chat/controller/ResponseMessage.java new file mode 100644 index 0000000..e5cd18f --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/controller/ResponseMessage.java @@ -0,0 +1,17 @@ +package com.leets.X.domain.chat.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResponseMessage { + + CHATROOM_CREATE_SUCCESS(201,"채팅방 생성에 성공했습니다."), + GET_CHATROOM(200, "하나의 채팅방을 반환합니다."), + GET_ROOMID(200, "채팅방 번호를 반환합니다."), + GET_CHATTING_LIST(200, "모든 채팅방 목록을 반환합니다."); + + private final int code; + private final String message; +} diff --git a/src/main/java/com/leets/X/domain/chat/dto/PublishMessage.java b/src/main/java/com/leets/X/domain/chat/dto/PublishMessage.java index 0c0aae4..6cb9b93 100644 --- a/src/main/java/com/leets/X/domain/chat/dto/PublishMessage.java +++ b/src/main/java/com/leets/X/domain/chat/dto/PublishMessage.java @@ -25,6 +25,8 @@ public class PublishMessage implements Serializable { private Long senderId; + private String senderName; + private String content; } diff --git a/src/main/java/com/leets/X/domain/chat/dto/request/ChatRoomCheckRequstDto.java b/src/main/java/com/leets/X/domain/chat/dto/request/ChatRoomCheckRequstDto.java new file mode 100644 index 0000000..0a61626 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/request/ChatRoomCheckRequstDto.java @@ -0,0 +1,11 @@ +package com.leets.X.domain.chat.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record ChatRoomCheckRequstDto( + + @NotNull Long user1Id, + @NotNull Long user2Id + +) { +} diff --git a/src/main/java/com/leets/X/domain/chat/dto/request/FindChatRoomRequestDto.java b/src/main/java/com/leets/X/domain/chat/dto/request/FindChatRoomRequestDto.java new file mode 100644 index 0000000..a984c93 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/request/FindChatRoomRequestDto.java @@ -0,0 +1,9 @@ +package com.leets.X.domain.chat.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record FindChatRoomRequestDto( + + @NotNull Long user1Id, + @NotNull Long user2Id +){} diff --git a/src/main/java/com/leets/X/domain/chat/dto/request/GetChatRoomRequestDto.java b/src/main/java/com/leets/X/domain/chat/dto/request/GetChatRoomRequestDto.java new file mode 100644 index 0000000..e408ae4 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/request/GetChatRoomRequestDto.java @@ -0,0 +1,12 @@ +package com.leets.X.domain.chat.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record GetChatRoomRequestDto( + + @NotNull Long roomId, + @NotNull Integer size, + @NotNull Integer page + +){ +} diff --git a/src/main/java/com/leets/X/domain/chat/dto/request/MessageDto.java b/src/main/java/com/leets/X/domain/chat/dto/request/MessageDto.java index 3527af5..384eb89 100644 --- a/src/main/java/com/leets/X/domain/chat/dto/request/MessageDto.java +++ b/src/main/java/com/leets/X/domain/chat/dto/request/MessageDto.java @@ -19,6 +19,8 @@ public class MessageDto implements Serializable { private Long senderId; + private String senderName; + private String content; } diff --git a/src/main/java/com/leets/X/domain/chat/dto/response/ChatMessageResponseDto.java b/src/main/java/com/leets/X/domain/chat/dto/response/ChatMessageResponseDto.java new file mode 100644 index 0000000..ca61f6b --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/response/ChatMessageResponseDto.java @@ -0,0 +1,23 @@ +package com.leets.X.domain.chat.dto.response; + +import com.leets.X.domain.chat.entity.ChatMessage; + +import java.time.LocalDateTime; + +public record ChatMessageResponseDto( + + Long senderId, + String senderName, + String content, + LocalDateTime createdAt + +) { + public static ChatMessageResponseDto fromEntity(ChatMessage chatMessage){ + return new ChatMessageResponseDto( + chatMessage.getSenderId(), + chatMessage.getSenderName(), + chatMessage.getContent(), + chatMessage.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/dto/response/ChatRoomResponseDto.java b/src/main/java/com/leets/X/domain/chat/dto/response/ChatRoomResponseDto.java new file mode 100644 index 0000000..5341efd --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/response/ChatRoomResponseDto.java @@ -0,0 +1,7 @@ +package com.leets.X.domain.chat.dto.response; + +public record ChatRoomResponseDto ( + + Long roomId + +){} diff --git a/src/main/java/com/leets/X/domain/chat/dto/response/ChattingDto.java b/src/main/java/com/leets/X/domain/chat/dto/response/ChattingDto.java new file mode 100644 index 0000000..ef4736f --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/response/ChattingDto.java @@ -0,0 +1,12 @@ +package com.leets.X.domain.chat.dto.response; + +import java.util.List; + +public record ChattingDto( + Long user1Id, + Long user2Id, + String user1Name, + String user2Name, + List chatMessageList +) { +} diff --git a/src/main/java/com/leets/X/domain/chat/dto/response/ChattingListResponseDto.java b/src/main/java/com/leets/X/domain/chat/dto/response/ChattingListResponseDto.java new file mode 100644 index 0000000..742ad39 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/response/ChattingListResponseDto.java @@ -0,0 +1,23 @@ +package com.leets.X.domain.chat.dto.response; + +import com.leets.X.domain.chat.entity.ChatRoom; + +public record ChattingListResponseDto( + Long roomId, + Long user1Id, + Long user2Id, + String user1Name, + String user2Name, + LatestMessageDto latestMessageDto +) { + public static ChattingListResponseDto of(ChatRoom chatRoom, LatestMessageDto latestMessageDto) { + return new ChattingListResponseDto( + chatRoom.getId(), + chatRoom.getUser1().getId(), + chatRoom.getUser2().getId(), + chatRoom.getUser1().getCustomId(), + chatRoom.getUser2().getCustomId(), + latestMessageDto + ); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/dto/response/LatestMessageDto.java b/src/main/java/com/leets/X/domain/chat/dto/response/LatestMessageDto.java new file mode 100644 index 0000000..fd2edb7 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/dto/response/LatestMessageDto.java @@ -0,0 +1,17 @@ +package com.leets.X.domain.chat.dto.response; + +import com.leets.X.domain.chat.entity.ChatMessage; + +import java.time.LocalDateTime; + + +public record LatestMessageDto( + + String content, + LocalDateTime createdAt + +) { + public static LatestMessageDto of(ChatMessage message) { + return new LatestMessageDto(message.getContent(), message.getCreatedAt()); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/entity/ChatMessage.java b/src/main/java/com/leets/X/domain/chat/entity/ChatMessage.java index 253e78c..767cf21 100644 --- a/src/main/java/com/leets/X/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/leets/X/domain/chat/entity/ChatMessage.java @@ -1,6 +1,7 @@ package com.leets.X.domain.chat.entity; +import com.fasterxml.jackson.annotation.JsonFormat; import com.leets.X.domain.chat.dto.PublishMessage; import com.leets.X.global.common.domain.BaseTimeEntity; import jakarta.persistence.Id; @@ -10,25 +11,32 @@ import java.time.LocalDateTime; -@Document +@Document(collection = "chatMessage") @Getter @Builder -public class ChatMessage extends BaseTimeEntity { +public class ChatMessage { @Id - private Long id; // MongoDb에서 사용하는 ObjectId + private String id; // MongoDb에서 사용하는 ObjectId private Long roomId; private Long senderId; + private String senderName; + private String content; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + public static ChatMessage of(PublishMessage publishMessage) { return ChatMessage.builder() .roomId(publishMessage.getRoomId()) .senderId(publishMessage.getSenderId()) + .senderName(publishMessage.getSenderName()) .content(publishMessage.getContent()) + .createdAt(LocalDateTime.now()) .build(); } diff --git a/src/main/java/com/leets/X/domain/chat/entity/ChatRoom.java b/src/main/java/com/leets/X/domain/chat/entity/ChatRoom.java index ba88dbe..50075d3 100644 --- a/src/main/java/com/leets/X/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/leets/X/domain/chat/entity/ChatRoom.java @@ -6,9 +6,9 @@ import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; @Builder +@Getter @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) // 기본 생성자 접근 레벨 PROTECTED @Entity @@ -28,4 +28,13 @@ public class ChatRoom extends BaseTimeEntity { private User user2; private String lastMessage; + + public static ChatRoom of(User user1, User user2) { + + return ChatRoom.builder() + .user1(user1) + .user2(user2) + .lastMessage("") + .build(); + } } diff --git a/src/main/java/com/leets/X/domain/chat/exception/ErrorMessage.java b/src/main/java/com/leets/X/domain/chat/exception/ErrorMessage.java new file mode 100644 index 0000000..c0c91a0 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/exception/ErrorMessage.java @@ -0,0 +1,15 @@ +package com.leets.X.domain.chat.exception; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + + NOT_FOUND_CHATROOM(400, "해당 채팅방을 찾을 수 없습니다."); + + private final int code; + private final String message; +} diff --git a/src/main/java/com/leets/X/domain/chat/exception/NotFoundChatRoomException.java b/src/main/java/com/leets/X/domain/chat/exception/NotFoundChatRoomException.java new file mode 100644 index 0000000..0186cb4 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/exception/NotFoundChatRoomException.java @@ -0,0 +1,10 @@ +package com.leets.X.domain.chat.exception; + +import com.leets.X.global.common.exception.BaseException; +import static com.leets.X.domain.chat.exception.ErrorMessage.NOT_FOUND_CHATROOM; + +public class NotFoundChatRoomException extends BaseException { + public NotFoundChatRoomException() { + super(NOT_FOUND_CHATROOM.getCode(), NOT_FOUND_CHATROOM.getMessage()); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/redis/RedisListener.java b/src/main/java/com/leets/X/domain/chat/redis/RedisListener.java new file mode 100644 index 0000000..28f89ee --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/redis/RedisListener.java @@ -0,0 +1,19 @@ +package com.leets.X.domain.chat.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +@Configuration +@RequiredArgsConstructor +public class RedisListener { + + private final RedisMessageListenerContainer redisMessageListener; + private final RedisSubscriber redisSubscriber; + + public void adaptMessageListener(Long roomId) { + ChannelTopic topic = new ChannelTopic("/sub/chats/" + roomId); + redisMessageListener.addMessageListener(redisSubscriber, topic); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/redis/RedisPublisher.java b/src/main/java/com/leets/X/domain/chat/redis/RedisPublisher.java new file mode 100644 index 0000000..8525625 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/redis/RedisPublisher.java @@ -0,0 +1,22 @@ +package com.leets.X.domain.chat.redis; + +import com.leets.X.domain.chat.dto.PublishMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisPublisher { + + private final RedisTemplate redisTemplate; + + public void publish(PublishMessage publishMessage) { + ChannelTopic topic = new ChannelTopic("/sub/chats/" + publishMessage.getRoomId()); + redisTemplate.convertAndSend(topic.getTopic(), publishMessage); + log.info("Redis 서버에 메시지 전송 완료"); + } +} diff --git a/src/main/java/com/leets/X/domain/chat/redis/RedisSubscriber.java b/src/main/java/com/leets/X/domain/chat/redis/RedisSubscriber.java index ae0234e..0a3a079 100644 --- a/src/main/java/com/leets/X/domain/chat/redis/RedisSubscriber.java +++ b/src/main/java/com/leets/X/domain/chat/redis/RedisSubscriber.java @@ -28,13 +28,10 @@ public void onMessage(Message message, byte[] pattern) { log.info("구독자 전송 전 message: {}", tmpMessage); try { - // 역직렬화한 문자열을 PublishMessage로 변환 PublishMessage publishMessage = obejctMapper.readValue(tmpMessage, PublishMessage.class); - messageTemplate.convertAndSend("/sub/chats/" + publishMessage.getRoomId(), publishMessage); log.info("구독자 전송 후 message: {}", publishMessage.getContent()); } catch (JsonProcessingException e) { - e.printStackTrace(); throw new RuntimeException(e); } } diff --git a/src/main/java/com/leets/X/domain/chat/repository/ChatMessageRepository.java b/src/main/java/com/leets/X/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..538b8ba --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,17 @@ +package com.leets.X.domain.chat.repository; + +import com.leets.X.domain.chat.entity.ChatMessage; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface ChatMessageRepository extends MongoRepository { + + Page findByRoomIdOrderByCreatedAtDesc(Long roomId, Pageable pageable); + + Optional findTopByRoomIdOrderByCreatedAtDesc(Long roomId); + + +} diff --git a/src/main/java/com/leets/X/domain/chat/repository/ChatRoomRepository.java b/src/main/java/com/leets/X/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..f33bfd0 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,45 @@ +package com.leets.X.domain.chat.repository; + +import com.leets.X.domain.chat.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.Optional; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + +/* + SELECT room_id FROM chat_room WHERE + (user1_id = :user1Id AND user2_id = :user2Id) OR + (user1_id = :user2Id AND user2_id = :user1Id); +*/ + + @Query("SELECT c.id FROM ChatRoom c WHERE " + + "(c.user1.id = :user1Id AND c.user2.id = :user2Id) OR " + + "(c.user1.id = :user2Id AND c.user2.id = :user1Id)") + Optional findRoomIdByUserIds(@Param("user1Id") Long user1Id, @Param("user2Id") Long user2Id); + + /* + * SELECT room_id FROM chat_room WHERE + * (user1_id =:user1Id) OR (user2_id=:user1Id) + * + * Fetch Join 적용 X + * @Query("SELECT c FROM ChatRoom c WHERE" + + " (c.user1.id =:userId) OR" + + " (c.user2.id =:userId)") + * + * */ + + + // Fetch Join으로 성능 향상 N+1문제 해결 + @Query("SELECT c FROM ChatRoom c " + + "JOIN FETCH c.user1 " + + "JOIN FETCH c.user2 " + + "WHERE c.user1.id = :userId OR c.user2.id = :userId") + Optional> findRoomsByUserId(Long userId); + +} diff --git a/src/main/java/com/leets/X/domain/chat/service/ChatRoomService.java b/src/main/java/com/leets/X/domain/chat/service/ChatRoomService.java new file mode 100644 index 0000000..0bb610f --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/service/ChatRoomService.java @@ -0,0 +1,50 @@ +package com.leets.X.domain.chat.service; + +import com.leets.X.domain.chat.dto.request.ChatRoomCheckRequstDto; +import com.leets.X.domain.chat.dto.request.FindChatRoomRequestDto; +import com.leets.X.domain.chat.dto.response.*; +import com.leets.X.domain.chat.entity.ChatRoom; +import com.leets.X.domain.chat.exception.NotFoundChatRoomException; +import com.leets.X.domain.chat.redis.RedisListener; +import com.leets.X.domain.chat.repository.ChatRoomRepository; +import com.leets.X.domain.user.domain.User; +import com.leets.X.domain.user.exception.UserNotFoundException; +import com.leets.X.domain.user.repository.UserRepository; +import com.leets.X.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatRoomService { + + private final RedisListener redisMessageListener; + private final ChatRoomRepository chatRoomRepository; + private final UserService userService; + + public ChatRoomResponseDto saveChatRoom(FindChatRoomRequestDto findChatRoomRequestDto) { + User user1 = userService.find(findChatRoomRequestDto.user1Id()); + User user2 = userService.find(findChatRoomRequestDto.user2Id()); + + ChatRoom savedRoom = chatRoomRepository.save(ChatRoom.of(user1, user2)); // 채팅방 RDB에 저장 + redisMessageListener.adaptMessageListener(savedRoom.getId()); // 리스너 등록 + return new ChatRoomResponseDto(savedRoom.getId()); + } + + + + public ChatRoomResponseDto findUser1User2ChatRoom(Long user1Id , Long user2Id) { + Long result = chatRoomRepository.findRoomIdByUserIds(user1Id , user2Id) + .orElseThrow(NotFoundChatRoomException::new); + return new ChatRoomResponseDto(result); + } + + // 테스트를 위해서 만들어둠. 추후 삭제 + public void addListener(Long roomId) { + redisMessageListener.adaptMessageListener(roomId); // 리스너 등록 + } + +} diff --git a/src/main/java/com/leets/X/domain/chat/service/ChattingService.java b/src/main/java/com/leets/X/domain/chat/service/ChattingService.java new file mode 100644 index 0000000..e7af8fa --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/service/ChattingService.java @@ -0,0 +1,74 @@ +package com.leets.X.domain.chat.service; + +import com.leets.X.domain.chat.dto.request.GetChatRoomRequestDto; +import com.leets.X.domain.chat.dto.response.ChatMessageResponseDto; +import com.leets.X.domain.chat.dto.response.ChattingDto; +import com.leets.X.domain.chat.dto.response.ChattingListResponseDto; +import com.leets.X.domain.chat.dto.response.LatestMessageDto; +import com.leets.X.domain.chat.entity.ChatMessage; +import com.leets.X.domain.chat.entity.ChatRoom; +import com.leets.X.domain.chat.exception.NotFoundChatRoomException; +import com.leets.X.domain.chat.redis.RedisListener; +import com.leets.X.domain.chat.repository.ChatMessageRepository; +import com.leets.X.domain.chat.repository.ChatRoomRepository; +import com.leets.X.domain.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ChattingService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final RedisListener redisMessageListener; + + public ChattingDto getChatRoom(Long roomId, Integer page, Integer size) { + ChatRoom findRoom = validateChatRoom(roomId); + User user1 = findRoom.getUser1(); + User user2 = findRoom.getUser2(); + redisMessageListener.adaptMessageListener(findRoom.getId()); // 채팅방 내역 조회시 리스너 등록 추가 (운영 시 삭제) + + List chatMessageList = generateChatRoomMessages(roomId, page, size); + return new ChattingDto(user1.getId(), user2.getId(), user1.getCustomId(), user2.getCustomId(), chatMessageList); + } + + + public List getChattingList(Long userId) { // 추후 JWT 파싱으로 받아내기. + List chatRooms = validateChatRommList(userId); + + return chatRooms.stream() + .map(chatRoom -> { + ChatMessage latestMessage = chatMessageRepository.findTopByRoomIdOrderByCreatedAtDesc(chatRoom.getId()).orElse(null); + LatestMessageDto latestMessageDto = (latestMessage != null) + ? LatestMessageDto.of(latestMessage) : new LatestMessageDto("", null); + return ChattingListResponseDto.of(chatRoom, latestMessageDto); + }) + .collect(Collectors.toList()); + } + + private List generateChatRoomMessages(Long roomId, Integer page, Integer size) { + return chatMessageRepository.findByRoomIdOrderByCreatedAtDesc( + roomId, PageRequest.of(page- 1, size)) + .getContent() + .stream() + .map(ChatMessageResponseDto::fromEntity) + .collect(Collectors.toList()); + } + + + private ChatRoom validateChatRoom(Long roomId) { + return chatRoomRepository.findById(roomId) + .orElseThrow(NotFoundChatRoomException::new); + } + + private List validateChatRommList(Long userId) { + return chatRoomRepository.findRoomsByUserId(userId) + .orElseThrow(NotFoundChatRoomException::new); + } + +} diff --git a/src/main/java/com/leets/X/domain/chat/service/PublishMessageService.java b/src/main/java/com/leets/X/domain/chat/service/PublishMessageService.java new file mode 100644 index 0000000..e0624f7 --- /dev/null +++ b/src/main/java/com/leets/X/domain/chat/service/PublishMessageService.java @@ -0,0 +1,28 @@ +package com.leets.X.domain.chat.service; + +import com.leets.X.domain.chat.dto.PublishMessage; +import com.leets.X.domain.chat.dto.request.MessageDto; +import com.leets.X.domain.chat.entity.ChatMessage; +import com.leets.X.domain.chat.redis.RedisPublisher; +import com.leets.X.domain.chat.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublishMessageService { + + private final RedisPublisher redisPublisher; + private final ChatMessageRepository chatMessageRepository; + + public void publishMessage(MessageDto messageDto) { + PublishMessage publishMessage = + new PublishMessage(messageDto.getRoomId(), messageDto.getSenderId(), messageDto.getSenderName(), messageDto.getContent()); + + redisPublisher.publish(publishMessage); + chatMessageRepository.save(ChatMessage.of(publishMessage)); + } + +} diff --git a/src/main/java/com/leets/X/domain/follow/service/FollowService.java b/src/main/java/com/leets/X/domain/follow/service/FollowService.java index 048bb4c..f9e5c91 100644 --- a/src/main/java/com/leets/X/domain/follow/service/FollowService.java +++ b/src/main/java/com/leets/X/domain/follow/service/FollowService.java @@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -23,7 +24,7 @@ public class FollowService { private final UserService userService; @Transactional - public void follow(Long userId, String email){ + public void follow(Long userId, String email) { User follower = userService.find(email); User followed = userService.find(userId); @@ -32,62 +33,86 @@ public void follow(Long userId, String email){ Follow follow = followRepository.save(Follow.of(follower, followed)); follower.addFollowing(follow); + follower.updateFollowingCount(countFollowing(follower)); + followed.addFollower(follow); + followed.updateFollowerCount(countFollower(followed)); } - public List getFollowers(Long userId){ + public List getFollowers(Long userId) { User user = userService.find(userId); List followerList = user.getFollowerList(); return followerList.stream() .map(follow -> { - return FollowResponse.from(follow.getFollower()); }) + return FollowResponse.from(follow.getFollower()); + }) .toList(); } - public List getFollowings(Long userId){ + public List getFollowings(Long userId) { User user = userService.find(userId); List followingList = user.getFollowingList(); return followingList.stream() .map(follow -> { - return FollowResponse.from(follow.getFollowed()); }) + return FollowResponse.from(follow.getFollowed()); + }) .toList(); } @Transactional - public void unfollow(Long userId, String email){ + public void unfollow(Long userId, String email) { User follower = userService.find(email); User followed = userService.find(userId); Follow follow = check(follower.getId(), followed.getId()); follower.removeFollowing(follow); + follower.decreaseFollowingCount(); + followed.removeFollower(follow); + followed.decreaseFollowerCount(); followRepository.delete(follow); } - public Follow find(Long followerId, Long followedId){ + public Follow find(Long followerId, Long followedId) { return followRepository.findByFollowerIdAndFollowedId(followerId, followedId) .orElseThrow(FollowNotFoundException::new); } + private Long countFollower(User user) { + return user.getFollowerList().stream() + .map(follow -> { + return FollowResponse.from(follow.getFollower()); + }) + .count(); + } + + private Long countFollowing(User user) { + return user.getFollowingList().stream() + .map(follow -> { + return FollowResponse.from(follow.getFollowed()); + }) + .count(); + } + // 기존 팔로우 정보가 있는지, 나한테 요청을 하지 않는지 검증 - private void validate(Long followerId, Long followedId){ - if(followRepository.existsByFollowerIdAndFollowedId(followerId, followedId)){ + private void validate(Long followerId, Long followedId) { + if (followRepository.existsByFollowerIdAndFollowedId(followerId, followedId)) { throw new AlreadyFollowException(); } - if(followerId.equals(followedId)){ + if (followerId.equals(followedId)) { throw new InvalidFollowException(); } } // 팔로우 되어 있는지 확인 - private Follow check(Long followerId, Long followedId){ - if(!followRepository.existsByFollowerIdAndFollowedId(followerId, followedId)){ + private Follow check(Long followerId, Long followedId) { + if (!followRepository.existsByFollowerIdAndFollowedId(followerId, followedId)) { throw new InvalidUnfollowException(); } return find(followerId, followedId); diff --git a/src/main/java/com/leets/X/domain/image/domain/Image.java b/src/main/java/com/leets/X/domain/image/domain/Image.java index 4ad1eda..12ba864 100644 --- a/src/main/java/com/leets/X/domain/image/domain/Image.java +++ b/src/main/java/com/leets/X/domain/image/domain/Image.java @@ -1,6 +1,8 @@ package com.leets.X.domain.image.domain; +import com.leets.X.domain.image.dto.request.ImageDto; import com.leets.X.domain.post.domain.Post; +import com.leets.X.global.common.domain.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -9,7 +11,7 @@ @AllArgsConstructor @Builder @Getter -public class Image { +public class Image extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -20,8 +22,17 @@ public class Image { private String url; - @ManyToOne( fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_Id") private Post post; + public static Image from(ImageDto dto, Post post) { + return Image.builder() + .name(dto.name()) + .url(dto.url()) + .post(post) + .build(); + } + + } diff --git a/src/main/java/com/leets/X/domain/image/dto/request/ImageDto.java b/src/main/java/com/leets/X/domain/image/dto/request/ImageDto.java new file mode 100644 index 0000000..fea78e2 --- /dev/null +++ b/src/main/java/com/leets/X/domain/image/dto/request/ImageDto.java @@ -0,0 +1,11 @@ +package com.leets.X.domain.image.dto.request; + +public record ImageDto( + String name, + String url +) { + public static ImageDto of(String name, String url) { + return new ImageDto(name, url); + } +} + diff --git a/src/main/java/com/leets/X/domain/image/dto/response/ImageResponse.java b/src/main/java/com/leets/X/domain/image/dto/response/ImageResponse.java new file mode 100644 index 0000000..edb120d --- /dev/null +++ b/src/main/java/com/leets/X/domain/image/dto/response/ImageResponse.java @@ -0,0 +1,13 @@ +package com.leets.X.domain.image.dto.response; + +import com.leets.X.domain.image.domain.Image; + +public record ImageResponse( + Long id, + String name, + String url +) { + public static ImageResponse from(Image image) { + return new ImageResponse(image.getId(), image.getName(), image.getUrl()); + } +} diff --git a/src/main/java/com/leets/X/domain/image/exception/ErrorMessage.java b/src/main/java/com/leets/X/domain/image/exception/ErrorMessage.java new file mode 100644 index 0000000..5847ef4 --- /dev/null +++ b/src/main/java/com/leets/X/domain/image/exception/ErrorMessage.java @@ -0,0 +1,15 @@ +package com.leets.X.domain.image.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + + S3_UPLOAD_FAIL(500, "이미지 업로드 중 에러가 발생했습니다."); + + private final int code; + private final String message; + +} diff --git a/src/main/java/com/leets/X/domain/image/exception/S3UploadException.java b/src/main/java/com/leets/X/domain/image/exception/S3UploadException.java new file mode 100644 index 0000000..b918bd8 --- /dev/null +++ b/src/main/java/com/leets/X/domain/image/exception/S3UploadException.java @@ -0,0 +1,10 @@ +package com.leets.X.domain.image.exception; + +import com.leets.X.global.common.exception.BaseException; + +public class S3UploadException extends BaseException { + public S3UploadException() { + super(ErrorMessage.S3_UPLOAD_FAIL.getCode(), ErrorMessage.S3_UPLOAD_FAIL.getMessage()); + } + +} diff --git a/src/main/java/com/leets/X/domain/image/repository/ImageRepository.java b/src/main/java/com/leets/X/domain/image/repository/ImageRepository.java new file mode 100644 index 0000000..7af6c53 --- /dev/null +++ b/src/main/java/com/leets/X/domain/image/repository/ImageRepository.java @@ -0,0 +1,7 @@ +package com.leets.X.domain.image.repository; + +import com.leets.X.domain.image.domain.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/leets/X/domain/image/service/ImageService.java b/src/main/java/com/leets/X/domain/image/service/ImageService.java new file mode 100644 index 0000000..ad7cf2c --- /dev/null +++ b/src/main/java/com/leets/X/domain/image/service/ImageService.java @@ -0,0 +1,33 @@ +package com.leets.X.domain.image.service; + +import com.leets.X.domain.image.domain.Image; +import com.leets.X.domain.image.dto.request.ImageDto; +import com.leets.X.domain.image.repository.ImageRepository; +import com.leets.X.domain.post.domain.Post; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private final ImageUploadService imageUploadService; + private final ImageRepository imageRepository; + + @Transactional + public List save(List file, Post post) throws IOException { + List dtoList = imageUploadService.uploadImages(file); + + List imageList = dtoList.stream() + .map((ImageDto dto) -> Image.from(dto, post)) + .toList(); + + return imageRepository.saveAll(imageList); + } + +} diff --git a/src/main/java/com/leets/X/domain/image/service/ImageUploadService.java b/src/main/java/com/leets/X/domain/image/service/ImageUploadService.java new file mode 100644 index 0000000..eba5bc8 --- /dev/null +++ b/src/main/java/com/leets/X/domain/image/service/ImageUploadService.java @@ -0,0 +1,77 @@ +package com.leets.X.domain.image.service; + +import com.leets.X.domain.image.dto.request.ImageDto; +import com.leets.X.domain.image.exception.S3UploadException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ImageUploadService { + @Value("${aws.s3.bucketName}") + private String bucketName; + @Value("${aws.s3.region}") + private String region; + + private final S3Client s3Client; + + public List uploadImages(List files) throws IOException { + + List images = new ArrayList<>(); + + for (MultipartFile file : files) { + String originalName = file.getOriginalFilename(); + String fileName = generateFileName(originalName); + + try { + // PutObjectRequest 생성 및 설정 + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .contentType(file.getContentType()) + .build(); + + // S3에 파일 업로드 + PutObjectResponse response = s3Client.putObject( + putObjectRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + + // 업로드 성공 여부 확인 + if (response.sdkHttpResponse().isSuccessful()) { + // 업로드된 파일의 URL을 ImageDto로 추가 + images.add(ImageDto.of(originalName, generateFileUrl(fileName))); + } else { + throw new S3UploadException(); + } + } catch (S3Exception e) { + throw new S3UploadException(); + } + } + + return images; + } + // S3에 저장된 파일 URL 생성 + private String generateFileUrl(String fileName) { + return String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, fileName); + } + + // 파일 이름을 고유하게 생성하는 메서드 + private String generateFileName(String originalFileName) { + String uuid = UUID.randomUUID().toString(); + return uuid + "_" + originalFileName.replaceAll("\\s+", "_"); + } + +} diff --git a/src/main/java/com/leets/X/domain/post/controller/PostController.java b/src/main/java/com/leets/X/domain/post/controller/PostController.java index 365481c..6bec614 100644 --- a/src/main/java/com/leets/X/domain/post/controller/PostController.java +++ b/src/main/java/com/leets/X/domain/post/controller/PostController.java @@ -1,19 +1,23 @@ package com.leets.X.domain.post.controller; -import com.leets.X.domain.post.domain.Post; + import com.leets.X.domain.post.dto.request.PostRequestDTO; +import com.leets.X.domain.post.dto.response.ParentPostResponseDto; import com.leets.X.domain.post.dto.response.PostResponseDto; import com.leets.X.domain.post.service.PostService; import com.leets.X.global.common.response.ResponseDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; -import java.util.Map; + + //컨트롤러에서 ResponseDto만들게끔 @Tag(name = "POST") @RestController @@ -23,36 +27,46 @@ public class PostController { private final PostService postService; - + // 게시물 상세 조회(자식 게시물 까지 함께 조회됨) @GetMapping("/{id}") - @Operation(summary = "게시물 ID로 조회") - public ResponseDto getPost(@PathVariable Long id) { - PostResponseDto postResponseDto = postService.getPostResponse(id); + @Operation(summary = "게시물 ID로 상세 조회") + public ResponseDto getPost(@PathVariable Long id, @AuthenticationPrincipal String email) { + PostResponseDto postResponseDto = postService.getPostResponse(id, email); return ResponseDto.response(ResponseMessage.GET_POST_SUCCESS.getCode(), ResponseMessage.GET_POST_SUCCESS.getMessage(), postResponseDto); } + // 모든 부모게시물 조회 + @GetMapping("/all") + @Operation(summary = "전체 부모 글 조회") + public ResponseDto> getAllParentPosts(@AuthenticationPrincipal String email) { + List posts = postService.getAllParentPosts(email); + return ResponseDto.response(ResponseMessage.GET_ALL_PARENT_POSTS_SUCCESS.getCode(), ResponseMessage.GET_ALL_PARENT_POSTS_SUCCESS.getMessage(), posts); + } + @GetMapping("/likes") @Operation(summary = "좋아요 수로 정렬한 게시물 조회") - public ResponseDto> getPostsSortedByLikes() { - List posts = postService.getPostsSortedByLikes(); + public ResponseDto> getPostsSortedByLikes(@AuthenticationPrincipal String email) { + List posts = postService.getPostsSortedByLikes(email); return ResponseDto.response(ResponseMessage.GET_SORTED_BY_LIKES_SUCCESS.getCode(), ResponseMessage.GET_SORTED_BY_LIKES_SUCCESS.getMessage(), posts); } @GetMapping("/latest") @Operation(summary = "최신 게시물 조회") - public ResponseDto> getLatestPosts() { - List posts = postService.getLatestPosts(); + public ResponseDto> getLatestPosts(@AuthenticationPrincipal String email) { + List posts = postService.getLatestParentPosts(email); return ResponseDto.response(ResponseMessage.GET_LATEST_POST_SUCCESS.getCode(), ResponseMessage.GET_LATEST_POST_SUCCESS.getMessage(), posts); } - @PostMapping("/post") + @PostMapping(value = "/post", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "글 생성") - public ResponseDto createPost(@RequestBody PostRequestDTO postRequestDTO, @AuthenticationPrincipal String email) { + public ResponseDto createPost(@RequestPart PostRequestDTO postRequestDTO, + @RequestPart(value = "files", required = false) List files, + @AuthenticationPrincipal String email) throws IOException { // 인증된 사용자의 이메일을 `@AuthenticationPrincipal`을 통해 주입받음 - PostResponseDto postResponseDto = postService.createPost(postRequestDTO, email); + PostResponseDto postResponseDto = postService.createPost(postRequestDTO, files , email); return ResponseDto.response(ResponseMessage.POST_SUCCESS.getCode(), ResponseMessage.POST_SUCCESS.getMessage(), postResponseDto); } @@ -63,12 +77,23 @@ public ResponseDto addLike(@PathVariable Long postId, @AuthenticationPri return ResponseDto.response(ResponseMessage.ADD_LIKE_SUCCESS.getCode(), responseMessage); } + @PostMapping(value = "/{postId}/reply", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "답글 생성") + public ResponseDto createReply(@PathVariable Long postId, + @RequestPart PostRequestDTO postRequestDTO, + @RequestPart(value = "files", required = false) List files, + @AuthenticationPrincipal String email) throws IOException { + // 답글 생성 서비스 호출 (부모 ID를 직접 전달) + PostResponseDto postResponseDto = postService.createReply(postId, postRequestDTO, files, email); + return ResponseDto.response(ResponseMessage.REPLY_SUCCESS.getCode(), ResponseMessage.REPLY_SUCCESS.getMessage(), postResponseDto); + } + @DeleteMapping("/{postId}") @Operation(summary = "게시물 삭제") public ResponseDto deletePost(@PathVariable Long postId, @AuthenticationPrincipal String email) { - String responseMessage = postService.deletePost(postId, email); - return ResponseDto.response(ResponseMessage.POST_DELETED_SUCCESS.getCode(), responseMessage); + postService.deletePost(postId, email); + return ResponseDto.response(ResponseMessage.POST_DELETED_SUCCESS.getCode(), ResponseMessage.POST_DELETED_SUCCESS.getMessage()); } @@ -79,6 +104,4 @@ public ResponseDto cancelLike(@PathVariable Long postId, @Authentication return ResponseDto.response(ResponseMessage.LIKE_CANCEL_SUCCESS.getCode(), responseMessage); } - - } diff --git a/src/main/java/com/leets/X/domain/post/controller/ResponseMessage.java b/src/main/java/com/leets/X/domain/post/controller/ResponseMessage.java index 74fcfb1..d32c59d 100644 --- a/src/main/java/com/leets/X/domain/post/controller/ResponseMessage.java +++ b/src/main/java/com/leets/X/domain/post/controller/ResponseMessage.java @@ -13,7 +13,9 @@ public enum ResponseMessage { GET_LATEST_POST_SUCCESS(200, "최신 게시물 조회에 성공했습니다."), ADD_LIKE_SUCCESS(201, "좋아요가 추가되었습니다."), POST_DELETED_SUCCESS(200, "게시물이 성공적으로 삭제되었습니다."), - LIKE_CANCEL_SUCCESS(200, "좋아요가 성공적으로 취소되었습니다."); + LIKE_CANCEL_SUCCESS(200, "좋아요가 성공적으로 취소되었습니다."), + REPLY_SUCCESS(201, "답글이 생성되었습니다."), + GET_ALL_PARENT_POSTS_SUCCESS(200, "모든 게시글 조회에 성공하였습니다."); private final int code; private final String message; diff --git a/src/main/java/com/leets/X/domain/post/domain/Post.java b/src/main/java/com/leets/X/domain/post/domain/Post.java index d882a57..9721497 100644 --- a/src/main/java/com/leets/X/domain/post/domain/Post.java +++ b/src/main/java/com/leets/X/domain/post/domain/Post.java @@ -1,6 +1,5 @@ package com.leets.X.domain.post.domain; -import com.leets.X.domain.comment.domain.Comment; import com.leets.X.domain.image.domain.Image; import com.leets.X.domain.like.domain.Like; import com.leets.X.domain.post.domain.enums.IsDeleted; @@ -29,10 +28,18 @@ public class Post extends BaseTimeEntity { @JoinColumn(name = "user_id") private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Post parent; + + @OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List replies = new ArrayList<>(); //답글 리스트 + + @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE, orphanRemoval = true) private List likes = new ArrayList<>(); - @OneToMany(mappedBy = "post", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, fetch = FetchType.LAZY) + @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE, orphanRemoval = true) private List images = new ArrayList<>(); @@ -50,12 +57,8 @@ public class Post extends BaseTimeEntity { @Column(name = "like_count") private Long likeCount = 0L; // 기본값을 0L로 초기화하여 null을 방지 - public void incrementLikeCount() { - if (this.likeCount == null) { - this.likeCount = 1L; // null인 경우 1로 초기화 - } else { - this.likeCount++; - } + public void updateLikeCount(long newLikeCount) { + this.likeCount = newLikeCount; } public void decrementLikeCount() { @@ -82,13 +85,31 @@ public void delete() { // 정적 메서드로 글 생성 public static Post create(User user, String content) { return Post.builder() - .user(user) - .content(content) - .views(0) // 기본 조회 수 - .likeCount(0L) // 좋아요 갯수 추가 - .isDeleted(IsDeleted.ACTIVE) // 기본값 ACTIVE로 설정 - .images(new ArrayList<>()) // 빈 리스트로 초기화 - .build(); - } + .user(user) + .content(content) + .views(0) // 기본 조회 수 + .likeCount(0L) // 좋아요 갯수 추가 + .isDeleted(IsDeleted.ACTIVE) // 기본값 ACTIVE로 설정 + .images(new ArrayList<>()) // 빈 리스트로 초기화 + .build(); + } + + // 정적 팩토리 메서드 + public static Post create(User user, String content, Post parent) { + return Post.builder() + .user(user) + .content(content) + .views(0) // 기본값 설정 + .likeCount(0L) // 기본값 설정 + .isDeleted(IsDeleted.ACTIVE) // 기본값 설정 + .parent(parent) // 부모 글 설정 + .images(new ArrayList<>()) // 빈 리스트로 초기화 + .build(); + + } + + public void addImage(List images) { + this.images.addAll(images); // 기존 리스트에 이미지 추가 + } } diff --git a/src/main/java/com/leets/X/domain/post/dto/response/CommentResponseDto.java b/src/main/java/com/leets/X/domain/post/dto/response/CommentResponseDto.java index e653121..5a68b32 100644 --- a/src/main/java/com/leets/X/domain/post/dto/response/CommentResponseDto.java +++ b/src/main/java/com/leets/X/domain/post/dto/response/CommentResponseDto.java @@ -1,17 +1,11 @@ package com.leets.X.domain.post.dto.response; import com.leets.X.domain.comment.domain.Comment; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class CommentResponseDto { - private Long commentId; - private String content; +public record CommentResponseDto( + Long commentId, + String content +) { public static CommentResponseDto from(Comment comment) { return new CommentResponseDto(comment.getId(), comment.getContent()); } diff --git a/src/main/java/com/leets/X/domain/post/dto/response/ImageResponseDto.java b/src/main/java/com/leets/X/domain/post/dto/response/ImageResponseDto.java index e4b45b5..46d9266 100644 --- a/src/main/java/com/leets/X/domain/post/dto/response/ImageResponseDto.java +++ b/src/main/java/com/leets/X/domain/post/dto/response/ImageResponseDto.java @@ -1,18 +1,12 @@ package com.leets.X.domain.post.dto.response; import com.leets.X.domain.image.domain.Image; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class ImageResponseDto { - - private Long imageId; - private String url; +public record ImageResponseDto( + Long imageId, + String url +) { public static ImageResponseDto from(Image image) { return new ImageResponseDto(image.getId(), image.getUrl()); } diff --git a/src/main/java/com/leets/X/domain/post/dto/response/ParentPostResponseDto.java b/src/main/java/com/leets/X/domain/post/dto/response/ParentPostResponseDto.java new file mode 100644 index 0000000..72761dd --- /dev/null +++ b/src/main/java/com/leets/X/domain/post/dto/response/ParentPostResponseDto.java @@ -0,0 +1,46 @@ +package com.leets.X.domain.post.dto.response; + +import com.leets.X.domain.post.domain.Post; +import com.leets.X.domain.post.domain.enums.IsDeleted; +import com.leets.X.domain.user.domain.User; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public record ParentPostResponseDto( + Long id, + String content, + Integer views, + IsDeleted isDeleted, + LocalDateTime createdAt, + PostUserResponse user, + Long likeCount, + Boolean isLikedByUser, + List images +) { + public static ParentPostResponseDto from(Post post, boolean isLikedByUser) { + return new ParentPostResponseDto( + post.getId(), + post.getContent(), + post.getViews(), + post.getIsDeleted(), + post.getCreatedAt(), + convertUser(post.getUser()), // User 변환 + post.getLikesCount(), + isLikedByUser, // 좋아요 여부 설정 + convertImagesToDtoList(post) // Images 변환 + ); +} + + private static PostUserResponse convertUser(User user) { + return user != null ? PostUserResponse.from(user) : null; + } + + private static List convertImagesToDtoList(Post post) { + return post.getImages() != null ? post.getImages().stream() + .map(ImageResponseDto::from) + .collect(Collectors.toList()) : Collections.emptyList(); + } +} diff --git a/src/main/java/com/leets/X/domain/post/dto/response/PostResponseDto.java b/src/main/java/com/leets/X/domain/post/dto/response/PostResponseDto.java index 4d31e0f..0aad551 100644 --- a/src/main/java/com/leets/X/domain/post/dto/response/PostResponseDto.java +++ b/src/main/java/com/leets/X/domain/post/dto/response/PostResponseDto.java @@ -1,34 +1,34 @@ package com.leets.X.domain.post.dto.response; -import com.leets.X.domain.comment.domain.Comment; -import com.leets.X.domain.image.domain.Image; +import com.leets.X.domain.image.dto.response.ImageResponse; +import com.leets.X.domain.like.repository.LikeRepository; import com.leets.X.domain.post.domain.Post; import com.leets.X.domain.post.domain.enums.IsDeleted; import com.leets.X.domain.user.domain.User; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; + import java.time.LocalDateTime; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; + + +public record PostResponseDto( + Long id, + String content, + Integer views, + IsDeleted isDeleted, + LocalDateTime createdAt, + PostUserResponse user, + Long likeCount, + Boolean isLikedByUser, // 좋아요 여부 확인 + List images, + List replies +) { -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class PostResponseDto { - private Long id; - private String content; - private Integer views; - private IsDeleted isDeleted; - private LocalDateTime createdAt; - private PostUserResponse user; // 작성자 정보 - private List images; // 관련 이미지 리스트 - private Long likeCount; // 좋아요 개수 추가 - - public static PostResponseDto from(Post post) { + public static PostResponseDto from(Post post, boolean isLikedByUser) { return new PostResponseDto( post.getId(), post.getContent(), @@ -36,23 +36,48 @@ public static PostResponseDto from(Post post) { post.getIsDeleted(), post.getCreatedAt(), convertUser(post.getUser()), + post.getLikesCount(), + isLikedByUser, // 서비스에서 전달된 boolean 값 사용 convertImagesToDtoList(post), - post.getLikesCount() // 좋아요 개수 추가 - ); + convertRepliesToDtoList(post.getReplies()) + ); } + // 좋아요 여부를 확인하기 위한 메서드 오버로딩 + public static PostResponseDto from(Post post, User user, LikeRepository likeRepository) { + boolean isLikedByUser = user != null && likeRepository.existsByPostAndUser(post, user); // 좋아요 여부 확인 + + return new PostResponseDto( + post.getId(), + post.getContent(), + post.getViews(), + post.getIsDeleted(), + post.getCreatedAt(), + convertUser(post.getUser()), + post.getLikesCount(), + isLikedByUser, // 좋아요 여부를 동적으로 설정 + convertImagesToDtoList(post), + convertRepliesToDtoList(post.getReplies()) + ); + } + + + private static List convertRepliesToDtoList(List replies) { + return replies != null ? replies.stream() + .map(reply -> PostResponseDto.from(reply, false)) // 기본적으로 isLikedByUser를 false로 설정 + .collect(Collectors.toList()) : Collections.emptyList(); + } private static PostUserResponse convertUser(User user) { return user != null ? PostUserResponse.from(user) : null; } - private static List convertImagesToDtoList(Post post) { + private static List convertImagesToDtoList(Post post) { return post.getImages().stream() - .map(ImageResponseDto::from) + .map(ImageResponse::from) .toList(); } - } diff --git a/src/main/java/com/leets/X/domain/post/dto/response/PostUserResponse.java b/src/main/java/com/leets/X/domain/post/dto/response/PostUserResponse.java index 1e92273..9e303fb 100644 --- a/src/main/java/com/leets/X/domain/post/dto/response/PostUserResponse.java +++ b/src/main/java/com/leets/X/domain/post/dto/response/PostUserResponse.java @@ -1,27 +1,18 @@ package com.leets.X.domain.post.dto.response; - - import com.leets.X.domain.user.domain.User; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class PostUserResponse { - - private Long userId; - private String name; - private String customId; +public record PostUserResponse( + Long userId, + String name, + String customId +) { public static PostUserResponse from(User user) { return new PostUserResponse( user.getId(), user.getName(), user.getCustomId() ); - } } diff --git a/src/main/java/com/leets/X/domain/post/repository/PostRepository.java b/src/main/java/com/leets/X/domain/post/repository/PostRepository.java index f9fc584..35ecc2c 100644 --- a/src/main/java/com/leets/X/domain/post/repository/PostRepository.java +++ b/src/main/java/com/leets/X/domain/post/repository/PostRepository.java @@ -1,11 +1,27 @@ package com.leets.X.domain.post.repository; import com.leets.X.domain.post.domain.Post; +import com.leets.X.domain.post.domain.enums.IsDeleted; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; +@Repository public interface PostRepository extends JpaRepository { List findTop10ByOrderByCreatedAtDesc(); // 최신 10개 게시물 조회하기위해 구현 + + +// 부모 글만 조회하고, 자식 글은 포함하지 않음 + List findByParentIsNullAndIsDeletedOrderByCreatedAtDesc(IsDeleted isDeleted); + + // 특정 부모 글과 자식 글을 함께 조회하는 메서드 (페치 조인 자동 생성) + Optional findWithRepliesByIdAndIsDeleted(Long postId, IsDeleted isDeleted); + } + + diff --git a/src/main/java/com/leets/X/domain/post/service/PostService.java b/src/main/java/com/leets/X/domain/post/service/PostService.java index f3b892d..25f9fa4 100644 --- a/src/main/java/com/leets/X/domain/post/service/PostService.java +++ b/src/main/java/com/leets/X/domain/post/service/PostService.java @@ -1,11 +1,13 @@ package com.leets.X.domain.post.service; +import com.leets.X.domain.image.domain.Image; +import com.leets.X.domain.image.service.ImageService; import com.leets.X.domain.like.domain.Like; import com.leets.X.domain.like.repository.LikeRepository; -import com.leets.X.domain.post.controller.ResponseMessage; import com.leets.X.domain.post.domain.Post; import com.leets.X.domain.post.domain.enums.IsDeleted; import com.leets.X.domain.post.dto.request.PostRequestDTO; +import com.leets.X.domain.post.dto.response.ParentPostResponseDto; import com.leets.X.domain.post.dto.response.PostResponseDto; import com.leets.X.domain.post.dto.response.PostUserResponse; import com.leets.X.domain.post.exception.AlreadyLikedException; @@ -16,12 +18,12 @@ import com.leets.X.domain.user.domain.User; import com.leets.X.domain.user.exception.UserNotFoundException; import com.leets.X.domain.user.service.UserService; -import com.leets.X.global.common.response.ResponseDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDateTime; +import java.io.IOException; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -33,84 +35,120 @@ public class PostService { private final PostRepository postRepository; private final UserService userService; private final LikeRepository likeRepository; + private final ImageService imageService; - // 게시물 ID로 조회 - public PostResponseDto getPostResponse(Long id) { - Post post = findPost(id); - // 삭제되지 않은 게시물만 조회 가능하게끔 수정 - if (post.getIsDeleted() != IsDeleted.ACTIVE) { - throw new PostNotFoundException(); - } - return PostResponseDto.from(post); + // 모든 부모 글만 조회 (자식 글 제외) + public List getAllParentPosts(String email) { + User user = userService.find(email); + + List posts = postRepository.findByParentIsNullAndIsDeletedOrderByCreatedAtDesc(IsDeleted.ACTIVE); + + return posts.stream() + .map(post -> { + boolean isLikedByUser = likeRepository.existsByPostAndUser(post, user); + return ParentPostResponseDto.from(post, isLikedByUser); + }) + .collect(Collectors.toList()); } - // 좋아요 수로 정렬한 게시물 조회 (직접 구현) + // 전체 게시물 조회 (자식 글 포함) + public PostResponseDto getPostResponse(Long id, String email) { + Post post = postRepository.findWithRepliesByIdAndIsDeleted(id, IsDeleted.ACTIVE) + .orElseThrow(PostNotFoundException::new); + + User user = userService.find(email); + boolean isLikedByUser = likeRepository.existsByPostAndUser(post, user); - public List getPostsSortedByLikes() { - List posts = postRepository.findAll(); // 모든 게시물 조회 + return PostResponseDto.from(post, isLikedByUser); + } + + // 좋아요 순으로 게시물 조회 + public List getPostsSortedByLikes(String email) { + User user = userService.find(email); + List posts = postRepository.findAll(); return posts.stream() - .filter(post -> post.getIsDeleted() == IsDeleted.ACTIVE) // ACTIVE 상태만 필터링 - .sorted(Comparator.comparing(Post::getLikesCount).reversed()) // 좋아요 수 기준으로 내림차순 정렬 - .map(PostResponseDto::from) // DTO로 변환 + .filter(post -> post.getIsDeleted() == IsDeleted.ACTIVE) + .sorted(Comparator.comparing(Post::getLikesCount).reversed()) + .map(post -> { + boolean isLikedByUser = likeRepository.existsByPostAndUser(post, user); + return PostResponseDto.from(post, isLikedByUser); + }) .collect(Collectors.toList()); } - // 최신 게시물 조회 - public List getLatestPosts() { - List posts = postRepository.findTop10ByOrderByCreatedAtDesc(); // 최신 10개 게시물 조회 - // 현재 시각 가져오기 - LocalDateTime now = LocalDateTime.now(); + // 최신 부모 글 10개 조회 (자식 글 제외) + public List getLatestParentPosts(String email) { + User user = userService.find(email); + + List posts = postRepository.findByParentIsNullAndIsDeletedOrderByCreatedAtDesc(IsDeleted.ACTIVE) + .stream() + .limit(10) + .collect(Collectors.toList()); - // 게시물을 PostResponseDto로 변환하고 정렬 return posts.stream() - .map(PostResponseDto::from) // Post 객체를 PostResponseDto로 변환 - .sorted(Comparator.comparingLong(postResponseDto -> - Math.abs(postResponseDto.getCreatedAt().until(now, java.time.temporal.ChronoUnit.SECONDS)))) // 현재 시각과의 차이를 기준으로 정렬 + .map(post -> { + boolean isLikedByUser = likeRepository.existsByPostAndUser(post, user); + return ParentPostResponseDto.from(post, isLikedByUser); + }) .collect(Collectors.toList()); } - // 글 생성 (Refactoring) + // 글 생성 @Transactional - public PostResponseDto createPost(PostRequestDTO postRequestDTO, String email) { - // 이메일로 사용자 조회 - User user = userService.find(email); // JWT에서 추출한 이메일 사용 + public PostResponseDto createPost(PostRequestDTO postRequestDTO,List files, String email) throws IOException { + User user = userService.find(email); if (user == null) { throw new UserNotFoundException(); } - Post post = Post.create(user, postRequestDTO.content()); // 글 생성 로직 캡슐화 + Post post = Post.create(user, postRequestDTO.content(), null); Post savedPost = postRepository.save(post); - // 저장된 게시글을 ResponseDto에 담아 반환 - return PostResponseDto.from(savedPost); + if (files != null) { + List images = imageService.save(files, savedPost); + savedPost.addImage(images); + } + + return PostResponseDto.from(savedPost, false); } - //좋아요 추가 + // 좋아요 추가 @Transactional public String addLike(Long postId, String email) { Post post = findPost(postId); User user = userService.find(email); - // 좋아요가 이미 있는지 확인 if (likeRepository.existsByPostAndUser(post, user)) { throw new AlreadyLikedException(); } - // 새로운 Like 객체 생성 및 저장 - Like newLike = new Like(post, user); - likeRepository.save(newLike); - - // 좋아요 수 증가 - post.incrementLikeCount(); - postRepository.save(post); + likeRepository.save(new Like(post, user)); + updateLikeCount(post); return "좋아요가 추가되었습니다. 현재 좋아요 수: " + post.getLikesCount(); } + // 답글 생성 + @Transactional + public PostResponseDto createReply(Long parentId, PostRequestDTO postRequestDTO, List files, String email) throws IOException { + User user = userService.find(email); + Post parentPost = findPost(parentId); + + Post reply = Post.create(user, postRequestDTO.content(), parentPost); + Post savedReply = postRepository.save(reply); + + if (files != null) { + List images = imageService.save(files, savedReply); + savedReply.addImage(images); + } + + return PostResponseDto.from(savedReply, false); + } + // 게시물 삭제 @Transactional - public String deletePost(Long postId, String email) { + public void deletePost(Long postId, String email) { Post post = findPost(postId); User user = userService.find(email); @@ -119,42 +157,41 @@ public String deletePost(Long postId, String email) { throw new UnauthorizedPostDeletionException(); } - // 게시물 상태를 삭제 상태로 변경 - post.delete(); // delete 메서드 호출로 상태를 변경 - postRepository.save(post); // 상태 업데이트 - - return "게시물이 삭제되었습니다."; + post.delete(); } + // 좋아요 취소 @Transactional public String cancelLike(Long postId, String email) { Post post = findPost(postId); User user = userService.find(email); - // 좋아요 여부 확인 후 삭제 if (!likeRepository.existsByPostAndUser(post, user)) { - throw new NotLikedException(); // 예외로 변경 + throw new NotLikedException(); } - // Like 객체 삭제 likeRepository.deleteByPostAndUser(post, user); - - // 좋아요 수 증가 - post.decrementLikeCount(); - postRepository.save(post); // likeCount를 데이터베이스에 업데이트 + updateLikeCount(post); return "좋아요가 삭제되었습니다. 현재 좋아요 수: " + post.getLikesCount(); } - // 자주 중복되는 코드 메서드로 뽑기 + // 좋아요 수 갱신 + @Transactional + public void updateLikeCount(Post post) { + long actualLikeCount = likeRepository.countByPost(post); + post.updateLikeCount(actualLikeCount); + postRepository.save(post); + } + public Post findPost(Long postId) { return postRepository.findById(postId) - .orElseThrow(() -> new PostNotFoundException()); + .orElseThrow(PostNotFoundException::new); } - // 사용자 정보를 가져와 PostUserResponse를 반환하는 메서드 public PostUserResponse findUser(String email) { User user = userService.find(email); - return PostUserResponse.from(user); // PostUserResponse로 변환하여 반환 + return PostUserResponse.from(user); } + } diff --git a/src/main/java/com/leets/X/domain/user/controller/UserController.java b/src/main/java/com/leets/X/domain/user/controller/UserController.java index d77aa43..b021151 100644 --- a/src/main/java/com/leets/X/domain/user/controller/UserController.java +++ b/src/main/java/com/leets/X/domain/user/controller/UserController.java @@ -5,7 +5,7 @@ import com.leets.X.domain.user.dto.request.UserUpdateRequest; import com.leets.X.domain.user.dto.response.UserProfileResponse; import com.leets.X.domain.user.dto.response.UserSocialLoginResponse; -import com.leets.X.domain.user.service.LoginStatus; +import com.leets.X.domain.user.domain.enums.LoginStatus; import com.leets.X.domain.user.service.UserService; import com.leets.X.global.common.response.ResponseDto; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/com/leets/X/domain/user/domain/User.java b/src/main/java/com/leets/X/domain/user/domain/User.java index 1a04422..f819441 100644 --- a/src/main/java/com/leets/X/domain/user/domain/User.java +++ b/src/main/java/com/leets/X/domain/user/domain/User.java @@ -50,6 +50,9 @@ public class User extends BaseTimeEntity { private String introduce; + private long followerCount = 0L; + + private long followingCount = 0L; @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true) private List posts = new ArrayList<>(); @@ -94,6 +97,25 @@ public void removeFollowing(Follow follow) { this.followingList.remove(follow); } + public void updateFollowerCount(long followerCount) { + this.followerCount = followerCount; + } + + public void decreaseFollowerCount() { + if(this.followerCount > 0){ + this.followerCount--; + } + } + + public void updateFollowingCount(long followingCount) { + this.followingCount = followingCount; + } + + public void decreaseFollowingCount() { + if(this.followingCount>0) { + this.followingCount--; + } + } public void addRepost(Repost repost) { this.reposts.add(repost); } diff --git a/src/main/java/com/leets/X/domain/user/service/LoginStatus.java b/src/main/java/com/leets/X/domain/user/domain/enums/LoginStatus.java similarity index 51% rename from src/main/java/com/leets/X/domain/user/service/LoginStatus.java rename to src/main/java/com/leets/X/domain/user/domain/enums/LoginStatus.java index e536f68..5eba020 100644 --- a/src/main/java/com/leets/X/domain/user/service/LoginStatus.java +++ b/src/main/java/com/leets/X/domain/user/domain/enums/LoginStatus.java @@ -1,4 +1,4 @@ -package com.leets.X.domain.user.service; +package com.leets.X.domain.user.domain.enums; public enum LoginStatus { LOGIN, REGISTER diff --git a/src/main/java/com/leets/X/domain/user/dto/response/UserProfileResponse.java b/src/main/java/com/leets/X/domain/user/dto/response/UserProfileResponse.java index 7d30cda..e3878ed 100644 --- a/src/main/java/com/leets/X/domain/user/dto/response/UserProfileResponse.java +++ b/src/main/java/com/leets/X/domain/user/dto/response/UserProfileResponse.java @@ -11,18 +11,24 @@ public record UserProfileResponse( Boolean isMyProfile, String name, String customId, - LocalDateTime createAt, - Long followCount, - Long followerCount -) { + Long followerCount, + Long followingCount, + Boolean isFollowing, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { // 정적 팩토리 메서드 - public static UserProfileResponse from(User user, Boolean isMyProfile) { + public static UserProfileResponse from(User user, Boolean isMyProfile, Boolean isFollowing) { return UserProfileResponse.builder() .userId(user.getId()) .isMyProfile(isMyProfile) .name(user.getName()) .customId(user.getCustomId()) - .createAt(user.getCreatedAt()) + .followerCount(user.getFollowerCount()) + .followingCount(user.getFollowingCount()) + .isFollowing(isFollowing) + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) .build(); } } diff --git a/src/main/java/com/leets/X/domain/user/dto/response/UserSocialLoginResponse.java b/src/main/java/com/leets/X/domain/user/dto/response/UserSocialLoginResponse.java index 3ce40f7..12765c7 100644 --- a/src/main/java/com/leets/X/domain/user/dto/response/UserSocialLoginResponse.java +++ b/src/main/java/com/leets/X/domain/user/dto/response/UserSocialLoginResponse.java @@ -1,6 +1,6 @@ package com.leets.X.domain.user.dto.response; -import com.leets.X.domain.user.service.LoginStatus; +import com.leets.X.domain.user.domain.enums.LoginStatus; import com.leets.X.global.auth.jwt.dto.JwtResponse; import lombok.Builder; diff --git a/src/main/java/com/leets/X/domain/user/exception/ErrorMessage.java b/src/main/java/com/leets/X/domain/user/exception/ErrorMessage.java index 7a7c134..5af4cb7 100644 --- a/src/main/java/com/leets/X/domain/user/exception/ErrorMessage.java +++ b/src/main/java/com/leets/X/domain/user/exception/ErrorMessage.java @@ -7,7 +7,11 @@ @AllArgsConstructor public enum ErrorMessage { - USER_NOT_FOUND(404,"존재하지 않는 유저입니다."); + USER_NOT_FOUND(404,"존재하지 않는 유저입니다."), + USER1_NOT_FOUND(404, "유저1은 존재하지 않습니다."), + USER2_NOT_FOUND(404, "유저2는 존재하지 않습니다."); + + // 송우석 추가 내용 private final int code; private final String message; diff --git a/src/main/java/com/leets/X/domain/user/service/UserService.java b/src/main/java/com/leets/X/domain/user/service/UserService.java index 07d5d38..88f7966 100644 --- a/src/main/java/com/leets/X/domain/user/service/UserService.java +++ b/src/main/java/com/leets/X/domain/user/service/UserService.java @@ -1,5 +1,6 @@ package com.leets.X.domain.user.service; +import com.leets.X.domain.follow.domain.Follow; import com.leets.X.domain.user.domain.User; import com.leets.X.domain.user.dto.request.UserInitializeRequest; import com.leets.X.domain.user.dto.request.UserUpdateRequest; @@ -17,8 +18,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static com.leets.X.domain.user.service.LoginStatus.LOGIN; -import static com.leets.X.domain.user.service.LoginStatus.REGISTER; +import java.util.List; + +import static com.leets.X.domain.user.domain.enums.LoginStatus.LOGIN; +import static com.leets.X.domain.user.domain.enums.LoginStatus.REGISTER; @Slf4j @Service @@ -70,12 +73,9 @@ public void updateProfile(UserUpdateRequest dto, String email){ public UserProfileResponse getProfile(Long userId, String email){ User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); - // 내 프로필이라면 true - if(user.getEmail().equals(email)){ - return UserProfileResponse.from(user, true); - } - // 아니라면 false - return UserProfileResponse.from(user, false); + boolean isMyProfile = user.getEmail().equals(email); + boolean isFollowing = checkFollowing(user, email); + return UserProfileResponse.from(user, isMyProfile, isFollowing); } // // @Transactional @@ -107,6 +107,15 @@ private JwtResponse generateToken (String email){ .build(); } + // 팔로우 되어있는 유저라면 true + private boolean checkFollowing(User target, String email){ + User source = find(email); + List followerList = target.getFollowerList(); + + return followerList.stream() + .anyMatch(follow -> follow.getFollower().getId().equals(source.getId())); + } + /* * userRepository에서 사용자를 검색하는 메서드 * 공통으로 사용되는 부분이 많기 때문에 별도로 분리 diff --git a/src/main/java/com/leets/X/global/auth/jwt/JwtFilter.java b/src/main/java/com/leets/X/global/auth/jwt/JwtFilter.java index fcb0c19..4f24e4f 100644 --- a/src/main/java/com/leets/X/global/auth/jwt/JwtFilter.java +++ b/src/main/java/com/leets/X/global/auth/jwt/JwtFilter.java @@ -23,7 +23,7 @@ public class JwtFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,FilterChain filterChain) throws ServletException, IOException { String token = getToken(request); try { diff --git a/src/main/java/com/leets/X/global/config/MongoDBConfig.java b/src/main/java/com/leets/X/global/config/MongoDBConfig.java new file mode 100644 index 0000000..1c93e02 --- /dev/null +++ b/src/main/java/com/leets/X/global/config/MongoDBConfig.java @@ -0,0 +1,28 @@ +package com.leets.X.global.config; + + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.config.EnableMongoAuditing; +import org.springframework.data.mongodb.core.convert.DbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; + +@Configuration +@EnableMongoRepositories("com.leets.X.domain.chat.repository") +@EnableMongoAuditing // 자동 검사 +public class MongoDBConfig { + + @Bean + public MappingMongoConverter mappingMongoConverter(MongoDatabaseFactory mongoDatabaseFactory, MongoMappingContext mongoMappingContext) { + DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDatabaseFactory); + MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, mongoMappingContext); + converter.setTypeMapper(new DefaultMongoTypeMapper(null)); + return converter; + } + +} diff --git a/src/main/java/com/leets/X/global/config/RedisConfig.java b/src/main/java/com/leets/X/global/config/RedisConfig.java index a60b2e5..18b8af6 100644 --- a/src/main/java/com/leets/X/global/config/RedisConfig.java +++ b/src/main/java/com/leets/X/global/config/RedisConfig.java @@ -4,6 +4,7 @@ import com.leets.X.domain.chat.redis.RedisSubscriber; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -11,6 +12,7 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -29,7 +31,7 @@ public class RedisConfig { public RedisConnectionFactory redisConnectionFactory(){ // Redis 연동 설정 Host 주소와 Post를 주입해준 redisStandaloneConfiguration를 RedisStandaloneConfiguration redisStandaloneConfiguration = - new RedisStandaloneConfiguration("localhost", 6379); + new RedisStandaloneConfiguration(redisHost, redisPort); // LettuceConnectionFactory의 생성자로 다시 넣어준다. return new LettuceConnectionFactory(redisStandaloneConfiguration); } @@ -54,6 +56,21 @@ public RedisTemplate chatRedisTemplate(RedisConnectionFa return chatRedisTemplate; } + // RedisMessageListnerContainer Bean 등록 + @Bean + public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory, + MessageListenerAdapter listenerAdapter, + ChannelTopic channelTopic) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + // listenerAdapter가 특정 채널(channelTopic)에서 발행된 메시지를 수신하도록 구성합니다. + container.setConnectionFactory(connectionFactory); + // 메시지 수신 준비 + 구독할 채널 설정 + // listenerAdapter가 특정 채널(channelTopic)에서 발행된 메시지를 수신하도록 구성합니다. 메시지 리스너 등록 +// container.addMessageListener(listenerAdapter, channelTopic); + return container; + } + + @Bean public ChannelTopic topic(){ return new ChannelTopic("/chatRoom"); diff --git a/src/main/java/com/leets/X/global/config/S3/S3Config.java b/src/main/java/com/leets/X/global/config/S3/S3Config.java new file mode 100644 index 0000000..8c41fb4 --- /dev/null +++ b/src/main/java/com/leets/X/global/config/S3/S3Config.java @@ -0,0 +1,36 @@ +package com.leets.X.global.config.S3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +@Configuration +public class S3Config { + + @Value("${aws.s3.accessKey}") + private String accessKey; + + @Value("${aws.s3.secretKey}") + private String secretKey; + + @Value("${aws.s3.region}") + private String region; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .serviceConfiguration(S3Configuration.builder() + .checksumValidationEnabled(true) + .build()) + .build(); + } +} diff --git a/src/main/java/com/leets/X/global/config/SecurityConfig.java b/src/main/java/com/leets/X/global/config/SecurityConfig.java index 2e8a638..38c70a4 100644 --- a/src/main/java/com/leets/X/global/config/SecurityConfig.java +++ b/src/main/java/com/leets/X/global/config/SecurityConfig.java @@ -51,8 +51,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { authorize .requestMatchers("/v3/api-docs", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**").permitAll() .requestMatchers("/api/v1/users/login").permitAll() - .requestMatchers("/api/v1/users/profile/{userId}").permitAll() + .requestMatchers("/ws","/ws/**").permitAll() .anyRequest().authenticated() + ) .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class) .exceptionHandling(exceptioHandling -> @@ -81,6 +82,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.addAllowedOriginPattern("http://localhost:3000"); + configuration.addAllowedOriginPattern("http://127.0.0.1:5500"); // HTML Live Server CORS 설정 configuration.addAllowedMethod("*"); configuration.addAllowedHeader("*"); configuration.addExposedHeader("Authorization"); diff --git a/src/main/java/com/leets/X/global/config/swagger/OctetStreamReadMsgConverter.java b/src/main/java/com/leets/X/global/config/swagger/OctetStreamReadMsgConverter.java new file mode 100644 index 0000000..7e24b2b --- /dev/null +++ b/src/main/java/com/leets/X/global/config/swagger/OctetStreamReadMsgConverter.java @@ -0,0 +1,33 @@ +package com.leets.X.global.config.swagger; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Type; + +@Component +public class OctetStreamReadMsgConverter extends AbstractJackson2HttpMessageConverter { + @Autowired + public OctetStreamReadMsgConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/com/leets/X/global/config/SwaggerConfig.java b/src/main/java/com/leets/X/global/config/swagger/SwaggerConfig.java similarity index 97% rename from src/main/java/com/leets/X/global/config/SwaggerConfig.java rename to src/main/java/com/leets/X/global/config/swagger/SwaggerConfig.java index 4579287..20ac88e 100644 --- a/src/main/java/com/leets/X/global/config/SwaggerConfig.java +++ b/src/main/java/com/leets/X/global/config/swagger/SwaggerConfig.java @@ -1,4 +1,4 @@ -package com.leets.X.global.config; +package com.leets.X.global.config.swagger; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 8a30912..0af36bc 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -13,9 +13,10 @@ spring: ddl-auto: update data: redis: - host: ${REDIS_HOST} port: ${REDIS_PORT} - + host: ${REDIS_HOST} + mongodb: + uri: mongodb://localhost:27017/testdb x: jwt: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 16b7564..76562b0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -16,7 +16,13 @@ spring: host: ${REDIS_HOST} port: ${REDIS_PORT} password: ${REDIS_PASSWORD} - + mongodb: + uri: mongodb://${MONGO_USER}:${MONGO_PASSWORD}@${MONGO_HOST}:${MONGO_PORT}/${MONGO_DATABASE} + # port: ${MONGO_PORT} + # host: ${MONGO_HOST} + # database: ${MONGO_DATABASE} + # username: ${MONGO_USER} + # password: ${MONGO_PASSWORD} x: jwt: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b84b45..e894830 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,13 @@ springdoc: operations-sorter: method tags-sorter: alpha +aws: + s3: + accessKey: ${S3_ACCESS_KEY} + secretKey: ${S3_SECRET_KEY} + bucketName: ${S3_BUCKET} + region: ap-northeast-2 + google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET}