From 5988147ec9caef2638fcf8edf007d199086b9afe Mon Sep 17 00:00:00 2001 From: beomzun Date: Mon, 24 Feb 2025 20:23:04 +0900 Subject: [PATCH 01/15] Build: add websocket, mongodb dependencies --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index 6cac408..1c099ef 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,10 @@ dependencies { //Mail implementation'org.springframework.boot:spring-boot-starter-mail' + + //chat + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' } tasks.named('test') { From d143058409c09b5f6093f0ed0a4ac709c3f42345 Mon Sep 17 00:00:00 2001 From: beomzun Date: Mon, 24 Feb 2025 20:25:09 +0900 Subject: [PATCH 02/15] Feat: Configure webSocketBroker to real-time chat --- .../webSocket/WebSocketBrokerConfig.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/main/java/org/example/plzdrawing/common/config/webSocket/WebSocketBrokerConfig.java diff --git a/src/main/java/org/example/plzdrawing/common/config/webSocket/WebSocketBrokerConfig.java b/src/main/java/org/example/plzdrawing/common/config/webSocket/WebSocketBrokerConfig.java new file mode 100644 index 0000000..fbe18db --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/config/webSocket/WebSocketBrokerConfig.java @@ -0,0 +1,25 @@ +package org.example.plzdrawing.common.config.webSocket; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws-chat") + .setAllowedOrigins("*") + .withSockJS(); + } +} From 52efb331c621f9c62a3d88e6c917ebc99931b72a Mon Sep 17 00:00:00 2001 From: beomzun Date: Wed, 26 Feb 2025 20:44:20 +0900 Subject: [PATCH 03/15] Refactor: change type about CustomerUser-memberId to Long --- .../plzdrawing/api/auth/customuser/CustomUser.java | 4 ++-- .../api/auth/customuser/CustomUserService.java | 2 +- .../api/auth/service/strategy/EmailService.java | 4 ++-- .../org/example/plzdrawing/util/jwt/TokenService.java | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUser.java b/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUser.java index 011974e..1049896 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUser.java +++ b/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUser.java @@ -8,9 +8,9 @@ @Getter public class CustomUser implements UserDetails { - private final String memberId; + private final Long memberId; - public CustomUser(String memberId) { + public CustomUser(Long memberId) { this.memberId = memberId; } diff --git a/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUserService.java b/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUserService.java index c3618b6..b1813a5 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUserService.java +++ b/src/main/java/org/example/plzdrawing/api/auth/customuser/CustomUserService.java @@ -18,6 +18,6 @@ public class CustomUserService implements UserDetailsService { @Transactional(readOnly = true) public CustomUser loadUserByUsername(String username) throws UsernameNotFoundException { memberService.findById(Long.valueOf(username)); - return new CustomUser(username); + return new CustomUser(Long.parseLong(username)); } } diff --git a/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java index 8ede6cc..f7395fd 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java +++ b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java @@ -33,8 +33,8 @@ public LoginResponse login(LoginRequest request) { validatePassword(request, member); - String accessToken = tokenService.createAccessToken(String.valueOf(member.getId())); - String refreshToken = tokenService.createRefreshToken(String.valueOf(member.getId())); + String accessToken = tokenService.createAccessToken(member.getId()); + String refreshToken = tokenService.createRefreshToken(member.getId()); return new LoginResponse(accessToken, refreshToken); } diff --git a/src/main/java/org/example/plzdrawing/util/jwt/TokenService.java b/src/main/java/org/example/plzdrawing/util/jwt/TokenService.java index 59db16e..e669fa4 100644 --- a/src/main/java/org/example/plzdrawing/util/jwt/TokenService.java +++ b/src/main/java/org/example/plzdrawing/util/jwt/TokenService.java @@ -12,18 +12,18 @@ public class TokenService { private final JwtTokenProvider jwtTokenProvider; - public String createAccessToken(String memberId) { - return jwtTokenProvider.createAccessToken(memberId); + public String createAccessToken(Long memberId) { + return jwtTokenProvider.createAccessToken(String.valueOf(memberId)); } - public String createRefreshToken(String memberId) { - return jwtTokenProvider.createRefreshToken(memberId); + public String createRefreshToken(Long memberId) { + return jwtTokenProvider.createRefreshToken(String.valueOf(memberId)); } public String reissue(String tokenHeader) { String refreshToken = removePrefix(tokenHeader); if (jwtTokenProvider.validationRefreshToken(refreshToken)) { - return createAccessToken(jwtTokenProvider.getMemberId(refreshToken)); + return createAccessToken(Long.parseLong(jwtTokenProvider.getMemberId(refreshToken))); } throw new RestApiException(TOKEN_INCORRECT.getErrorCode()); } From d45fba012b304d28ac73d26c0a3d4b48426f49a0 Mon Sep 17 00:00:00 2001 From: beomzun Date: Wed, 26 Feb 2025 22:21:30 +0900 Subject: [PATCH 04/15] Feat: implement ChatRoom creation and retrieval APIs --- .../controller/ChatRoomController.java | 42 ++++++++++ .../dto/converter/ChatRoomConverter.java | 14 ++++ .../dto/request/CreateChatRoomRequest.java | 11 +++ .../dto/response/ResponseChatRoom.java | 11 +++ .../ChatRoomAlreadyExistsException.java | 12 +++ .../chatRoom/exception/ChatRoomErrorCode.java | 16 ++++ .../api/chatRoom/service/ChatRoomService.java | 15 ++++ .../chatRoom/service/ChatRoomServiceImpl.java | 83 +++++++++++++++++++ .../api/member/service/MemberService.java | 3 + .../api/member/service/MemberServiceImpl.java | 5 ++ .../exception/GlobalExceptionHandler.java | 11 +++ .../plzdrawing/domain/chat/message/Chat.java | 46 ++++++++++ .../plzdrawing/domain/chat/room/ChatRoom.java | 49 +++++++++++ .../domain/chat/room/ChatRoomRepository.java | 12 +++ .../domain/member/MemberRepository.java | 3 + 15 files changed, 333 insertions(+) create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/dto/request/CreateChatRoomRequest.java create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java create mode 100644 src/main/java/org/example/plzdrawing/domain/chat/message/Chat.java create mode 100644 src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoom.java create mode 100644 src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoomRepository.java diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java b/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java new file mode 100644 index 0000000..0d7f2ab --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java @@ -0,0 +1,42 @@ +package org.example.plzdrawing.api.chatRoom.controller; + +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.auth.customuser.CustomUser; +import org.example.plzdrawing.api.chatRoom.dto.request.CreateChatRoomRequest; +import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; +import org.example.plzdrawing.api.chatRoom.service.ChatRoomService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/chatRooms") +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + + @PostMapping("/") + public ResponseEntity createChatRoom(@AuthenticationPrincipal CustomUser customUser, @Valid @RequestBody + CreateChatRoomRequest request) { + String chatRoomId = chatRoomService.createChatRoom(customUser.getMemberId(), request); + + URI uri = URI.create("/api/v1/chat/" + chatRoomId); + return ResponseEntity.created(uri).build(); + } + + @GetMapping("") + public ResponseEntity> getChatRooms(@AuthenticationPrincipal CustomUser customUser) { + List chatRooms = chatRoomService.getChatRooms(customUser.getMemberId()); + return ResponseEntity.ok(chatRooms); + } + + //TODO 읽지 않은 채팅 개수 +} diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java new file mode 100644 index 0000000..a9c8881 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java @@ -0,0 +1,14 @@ +package org.example.plzdrawing.api.chatRoom.dto.converter; + +import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; +import org.example.plzdrawing.domain.chat.room.ChatRoom; + +public class ChatRoomConverter { + public static ResponseChatRoom fromEntity(ChatRoom chatRoom, String counterpartNickname) { + return new ResponseChatRoom( + chatRoom.getChatRoomId(), + counterpartNickname, + chatRoom.getLastMessage() + ); + } +} diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/request/CreateChatRoomRequest.java b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/request/CreateChatRoomRequest.java new file mode 100644 index 0000000..0460b69 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/request/CreateChatRoomRequest.java @@ -0,0 +1,11 @@ +package org.example.plzdrawing.api.chatRoom.dto.request; + +import jakarta.validation.constraints.Positive; +import lombok.Getter; + +@Getter +public class CreateChatRoomRequest { + + @Positive(message = "판매자 id는 필수입니다.") + private Long sellerId; +} diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java new file mode 100644 index 0000000..633f832 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java @@ -0,0 +1,11 @@ +package org.example.plzdrawing.api.chatRoom.dto.response; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class ResponseChatRoom { + + private String chatRoomId; + private String counterpartNickname; + private String lastMessage; +} diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java new file mode 100644 index 0000000..f892db2 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java @@ -0,0 +1,12 @@ +package org.example.plzdrawing.api.chatRoom.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.domain.chat.room.ChatRoom; + +@Getter +@RequiredArgsConstructor +public class ChatRoomAlreadyExistsException extends RuntimeException { + + private final ChatRoom chatRoom; +} diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java new file mode 100644 index 0000000..7691336 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java @@ -0,0 +1,16 @@ +package org.example.plzdrawing.api.chatRoom.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.common.error.BaseErrorCode; +import org.example.plzdrawing.common.error.ErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ChatRoomErrorCode {//2 + CHATROOM_NOT_FOUND(new BaseErrorCode(4042, HttpStatus.NOT_FOUND,"존재하지 않는 채팅방입니다")) + ; + + private final ErrorCode errorCode; +} diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java new file mode 100644 index 0000000..a5bc415 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java @@ -0,0 +1,15 @@ +package org.example.plzdrawing.api.chatRoom.service; + +import java.util.List; +import org.example.plzdrawing.api.chatRoom.dto.request.CreateChatRoomRequest; +import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; +import org.example.plzdrawing.domain.chat.message.Chat; + +public interface ChatRoomService { + + List getChatRooms(Long memberId); + + String createChatRoom(Long memberId, CreateChatRoomRequest request); + + void updateLastMessage(Chat chat); +} diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java new file mode 100644 index 0000000..ea047e9 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java @@ -0,0 +1,83 @@ +package org.example.plzdrawing.api.chatRoom.service; + +import static org.example.plzdrawing.api.chatRoom.exception.ChatRoomErrorCode.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.chatRoom.dto.converter.ChatRoomConverter; +import org.example.plzdrawing.api.chatRoom.dto.request.CreateChatRoomRequest; +import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; +import org.example.plzdrawing.api.chatRoom.exception.ChatRoomAlreadyExistsException; +import org.example.plzdrawing.api.member.service.MemberService; +import org.example.plzdrawing.common.exception.RestApiException; +import org.example.plzdrawing.domain.chat.message.Chat; +import org.example.plzdrawing.domain.chat.room.ChatRoom; +import org.example.plzdrawing.domain.chat.room.ChatRoomRepository; +import org.example.plzdrawing.domain.member.Member; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ChatRoomServiceImpl implements ChatRoomService { + + private final MemberService memberService; + private final ChatRoomRepository chatRoomRepository; + @Override + @Transactional + public String createChatRoom(Long memberId, CreateChatRoomRequest request) { + Long sellerId = request.getSellerId(); + chatRoomRepository.findBySellerIdAndBuyerId(sellerId,memberId) + .ifPresent(existingRoom -> {throw new ChatRoomAlreadyExistsException(existingRoom);}); + + Map members = memberListToMap(memberService.findMembersByIds(createMemberIdList(memberId, sellerId))); + ChatRoom chatRoom = ChatRoom.create(members.get(sellerId), members.get(memberId)); + return chatRoomRepository.save(chatRoom).getChatRoomId(); + } + + @Override + @Transactional + public void updateLastMessage(Chat chat) { + ChatRoom chatRoom = chatRoomRepository.findById(chat.getChatRoomId()) + .orElseThrow(() -> new RestApiException(CHATROOM_NOT_FOUND.getErrorCode())); + chatRoom.updateLastMessage(chat.getMessage()); + } + + @Override + @Transactional(readOnly = true) + public List getChatRooms(Long memberId) { + List chatRooms = chatRoomRepository.findBySellerIdOrBuyerId(memberId, memberId); + return chatRooms.stream() + .map(chatRoom -> { + String counterpartNickname = pickCounterpartNickname(memberId,chatRoom); + return ChatRoomConverter.fromEntity(chatRoom, counterpartNickname); + }) + .collect(Collectors.toList()); + } + + + + private String pickCounterpartNickname(Long memberId, ChatRoom chatRoom) { + if(Objects.equals(memberId, chatRoom.getSellerId())) { + return chatRoom.getBuyerNickname(); + } + return chatRoom.getSellerNickname(); + } + + private List createMemberIdList(Long buyerId, Long sellerId) { + List memberIds = new ArrayList<>(); + memberIds.add(buyerId); + memberIds.add(sellerId); + return memberIds; + } + + private Map memberListToMap(List members) { + return members.stream() + .collect(Collectors.toMap(Member::getId, Function.identity())); + } +} diff --git a/src/main/java/org/example/plzdrawing/api/member/service/MemberService.java b/src/main/java/org/example/plzdrawing/api/member/service/MemberService.java index 2fe67f9..be7e472 100644 --- a/src/main/java/org/example/plzdrawing/api/member/service/MemberService.java +++ b/src/main/java/org/example/plzdrawing/api/member/service/MemberService.java @@ -1,5 +1,6 @@ package org.example.plzdrawing.api.member.service; +import java.util.List; import org.example.plzdrawing.domain.member.Member; import org.example.plzdrawing.domain.member.Provider; @@ -8,4 +9,6 @@ public interface MemberService { boolean isMemberExistsByEmailAndProvider(String email, Provider provider); Member findById(Long memberId); + + List findMembersByIds(List memberIds); } diff --git a/src/main/java/org/example/plzdrawing/api/member/service/MemberServiceImpl.java b/src/main/java/org/example/plzdrawing/api/member/service/MemberServiceImpl.java index d8054e8..1257d09 100644 --- a/src/main/java/org/example/plzdrawing/api/member/service/MemberServiceImpl.java +++ b/src/main/java/org/example/plzdrawing/api/member/service/MemberServiceImpl.java @@ -2,6 +2,7 @@ import static org.example.plzdrawing.api.member.exception.MemberErrorCode.MEMBER_NOT_FOUND; +import java.util.List; import lombok.RequiredArgsConstructor; import org.example.plzdrawing.common.exception.RestApiException; import org.example.plzdrawing.domain.member.Member; @@ -26,4 +27,8 @@ public Member findById(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(()->new RestApiException(MEMBER_NOT_FOUND.getErrorCode())); } + + public List findMembersByIds(List memberIds) { + return memberRepository.findByIdIn(memberIds); + } } diff --git a/src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java b/src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java index f7b4318..233529d 100644 --- a/src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java @@ -1,13 +1,16 @@ package org.example.plzdrawing.common.exception; +import java.net.URI; import java.util.List; import java.util.stream.Collectors; import lombok.NonNull; +import org.example.plzdrawing.api.chatRoom.exception.ChatRoomAlreadyExistsException; import org.example.plzdrawing.common.error.CommonErrorCode; import org.example.plzdrawing.common.error.ErrorResponse; import org.example.plzdrawing.common.error.ErrorResponse.ValidationError; import org.example.plzdrawing.common.error.ErrorCode; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; @@ -20,6 +23,14 @@ @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + @ExceptionHandler(ChatRoomAlreadyExistsException.class) + public ResponseEntity handleChatRoomAlreadyExists(ChatRoomAlreadyExistsException ex) { + String chatRoomId = ex.getChatRoom().getChatRoomId(); + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create("/api/v1/chat/"+chatRoomId)); + return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER); + } + @ExceptionHandler(RestApiException.class) public ResponseEntity handleCustomException(RestApiException e) { ErrorCode errorCode = e.getErrorCode(); diff --git a/src/main/java/org/example/plzdrawing/domain/chat/message/Chat.java b/src/main/java/org/example/plzdrawing/domain/chat/message/Chat.java new file mode 100644 index 0000000..f1b3b71 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/message/Chat.java @@ -0,0 +1,46 @@ +package org.example.plzdrawing.domain.chat.message; + +import java.sql.Timestamp; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Getter +@Document(collection = "chat_messages") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Chat { + + @Id + private String chatId; + + private String chatRoomId; + + private Long senderId; + private String message; + private MessageType messageType; + + private String fileUrl; + private String fileName; + private Long fileSize; + private String mimeType; + + private Timestamp timestamp; + + @Builder + private Chat(String chatRoomId, Long senderId, String message, MessageType messageType, + String fileUrl, String fileName, Long fileSize, String mimeType, + Timestamp timestamp) { + this.chatRoomId = chatRoomId; + this.senderId = senderId; + this.message = message; + this.messageType = messageType; + this.fileUrl = fileUrl; + this.fileName = fileName; + this.fileSize = fileSize; + this.mimeType = mimeType; + this.timestamp = timestamp; + } +} diff --git a/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoom.java b/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoom.java new file mode 100644 index 0000000..92ea152 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoom.java @@ -0,0 +1,49 @@ +package org.example.plzdrawing.domain.chat.room; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.plzdrawing.domain.member.Member; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Getter +@Document(collection = "chat_rooms") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatRoom { + + @Id + private String chatRoomId; + + private Long sellerId; + private Long buyerId; + private String sellerNickname; + private String buyerNickname; + + private String lastMessage; + + @Builder + private ChatRoom(Long sellerId, Long buyerId, String sellerNickname, + String buyerNickname, String lastMessage) { + this.sellerId = sellerId; + this.buyerId = buyerId; + this.sellerNickname = sellerNickname; + this.buyerNickname = buyerNickname; + this.lastMessage = lastMessage; + } + + public void updateLastMessage(String message) { + this.lastMessage = message; + } + + public static ChatRoom create(Member seller, Member buyer) { + return ChatRoom.builder() + .buyerId(buyer.getId()) + .buyerNickname(buyer.getNickname()) + .sellerId(seller.getId()) + .sellerNickname(seller.getNickname()) + .lastMessage("") + .build(); + } +} diff --git a/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoomRepository.java b/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoomRepository.java new file mode 100644 index 0000000..32b8dba --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoomRepository.java @@ -0,0 +1,12 @@ +package org.example.plzdrawing.domain.chat.room; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ChatRoomRepository extends MongoRepository { + + List findBySellerIdOrBuyerId(Long sellerId, Long buyerId); + + Optional findBySellerIdAndBuyerId(Long sellerId, Long buyerId); +} diff --git a/src/main/java/org/example/plzdrawing/domain/member/MemberRepository.java b/src/main/java/org/example/plzdrawing/domain/member/MemberRepository.java index 38d8cbb..1065332 100644 --- a/src/main/java/org/example/plzdrawing/domain/member/MemberRepository.java +++ b/src/main/java/org/example/plzdrawing/domain/member/MemberRepository.java @@ -1,5 +1,6 @@ package org.example.plzdrawing.domain.member; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,4 +12,6 @@ public interface MemberRepository extends JpaRepository { @Query("select m from Member m " + "where m.email=:email and m.provider=:provider") Optional findByEmailAndProvider(String email, Provider provider); + + List findByIdIn(List memberIds); } From 3ce13f27a8d99c0d6093f0c13b539366c53bb4bf Mon Sep 17 00:00:00 2001 From: beomzun Date: Wed, 26 Feb 2025 22:24:21 +0900 Subject: [PATCH 05/15] Feat: implement chat message sending and room entry APIs --- .../api/chat/controller/ChatController.java | 43 +++++++++++ .../api/chat/dto/converter/ChatConverter.java | 38 ++++++++++ .../api/chat/dto/request/ChatDto.java | 18 +++++ .../api/chat/dto/response/ResponseChat.java | 20 +++++ .../api/chat/service/ChatService.java | 13 ++++ .../api/chat/service/ChatServiceImpl.java | 42 +++++++++++ .../common/config/web/WebConfig.java | 15 +++- .../domain/chat/message/ChatRepository.java | 10 +++ .../domain/chat/message/MessageType.java | 5 ++ .../ChatRoomMembershipInterceptor.java | 73 +++++++++++++++++++ 10 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java create mode 100644 src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java create mode 100644 src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java create mode 100644 src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java create mode 100644 src/main/java/org/example/plzdrawing/api/chat/service/ChatService.java create mode 100644 src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java create mode 100644 src/main/java/org/example/plzdrawing/domain/chat/message/ChatRepository.java create mode 100644 src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java create mode 100644 src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java diff --git a/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java new file mode 100644 index 0000000..5d9773e --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java @@ -0,0 +1,43 @@ +package org.example.plzdrawing.api.chat.controller; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.chat.dto.request.ChatDto; +import org.example.plzdrawing.api.chat.dto.response.ResponseChat; +import org.example.plzdrawing.api.chat.service.ChatService; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/chat") +public class ChatController { + + private final SimpMessagingTemplate messagingTemplate; + private final ChatService chatService; + + @MessageMapping("/v1/chat") //app/v1/chat + public void sendMessage(@Payload ChatDto chatDto) { + chatService.saveMessage(chatDto, Timestamp.valueOf(LocalDateTime.now())); + messagingTemplate.convertAndSend("/topic/"+chatDto.getChatRoomId(), chatDto); + } + + @GetMapping("/{chatRoomId}") + public ResponseEntity> getChats(@PathVariable("chatRoomId") String chatRoomId, + @RequestParam(name = "pageNum", defaultValue = "0") int pageNum) { + Page chatPage = chatService.getChats(chatRoomId, pageNum); + + return ResponseEntity.ok(chatPage); + } + + //TODO 정렬 인덱싱-날짜 / 이미지,파일 저장 및 조회 / 채팅전송 validation +} diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java b/src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java new file mode 100644 index 0000000..49f9cae --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java @@ -0,0 +1,38 @@ +package org.example.plzdrawing.api.chat.dto.converter; + +import java.sql.Timestamp; +import org.example.plzdrawing.api.chat.dto.request.ChatDto; +import org.example.plzdrawing.api.chat.dto.response.ResponseChat; +import org.example.plzdrawing.domain.chat.message.Chat; + +public class ChatConverter { + + public static Chat toEntity(ChatDto dto, Timestamp sendTime) { + return Chat.builder() + .chatRoomId(dto.getChatRoomId()) + .senderId(dto.getSenderId()) + .message(dto.getMessage()) + .messageType(dto.getMessageType()) + .fileUrl(dto.getFileUrl()) + .fileName(dto.getFileName()) + .fileSize(dto.getFileSize()) + .mimeType(dto.getMimeType()) + .timestamp(sendTime) + .build(); + } + + public static ResponseChat fromEntity(Chat chat) { + return new ResponseChat( + chat.getChatId(), + chat.getChatRoomId(), + chat.getSenderId(), + chat.getMessage(), + chat.getTimestamp(), + chat.getMessageType(), + chat.getFileUrl(), + chat.getFileName(), + chat.getFileSize(), + chat.getMimeType() + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java b/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java new file mode 100644 index 0000000..e8a508f --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java @@ -0,0 +1,18 @@ +package org.example.plzdrawing.api.chat.dto.request; + +import lombok.Getter; +import org.example.plzdrawing.domain.chat.message.MessageType; + +@Getter +public class ChatDto { + + private String chatRoomId; + private Long senderId; + private String message; + private MessageType messageType; + + private String fileUrl; + private String fileName; + private Long fileSize; + private String mimeType; +} diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java b/src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java new file mode 100644 index 0000000..f6393a1 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java @@ -0,0 +1,20 @@ +package org.example.plzdrawing.api.chat.dto.response; + +import java.sql.Timestamp; +import lombok.AllArgsConstructor; +import org.example.plzdrawing.domain.chat.message.MessageType; + +@AllArgsConstructor +public class ResponseChat { + + private String chatId; + private String chatRoomId; + private Long senderId; + private String message; + private Timestamp timestamp; + private MessageType messageType; + private String FileUrl; + private String FileName; + private Long FileSize; + private String mimeType; +} diff --git a/src/main/java/org/example/plzdrawing/api/chat/service/ChatService.java b/src/main/java/org/example/plzdrawing/api/chat/service/ChatService.java new file mode 100644 index 0000000..9279707 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/service/ChatService.java @@ -0,0 +1,13 @@ +package org.example.plzdrawing.api.chat.service; + +import java.sql.Timestamp; +import org.example.plzdrawing.api.chat.dto.request.ChatDto; +import org.example.plzdrawing.api.chat.dto.response.ResponseChat; +import org.springframework.data.domain.Page; + +public interface ChatService { + + void saveMessage(ChatDto chatDto, Timestamp sendTime); + + Page getChats(String chatRoomId, int pageNum); +} diff --git a/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java b/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java new file mode 100644 index 0000000..55abf70 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java @@ -0,0 +1,42 @@ +package org.example.plzdrawing.api.chat.service; + +import java.sql.Timestamp; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.chat.dto.request.ChatDto; +import org.example.plzdrawing.api.chat.dto.response.ResponseChat; +import org.example.plzdrawing.api.chat.dto.converter.ChatConverter; +import org.example.plzdrawing.api.chatRoom.service.ChatRoomService; +import org.example.plzdrawing.domain.chat.message.Chat; +import org.example.plzdrawing.domain.chat.message.ChatRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ChatServiceImpl implements ChatService { + + private final ChatRepository chatRepository; + private final ChatRoomService chatRoomService; + private static final int PAGE_SIZE = 20; + + @Override + @Transactional + public void saveMessage(ChatDto chatDto, Timestamp sendTime) { + Chat chat = ChatConverter.toEntity(chatDto, sendTime); + chatRepository.save(chat); + chatRoomService.updateLastMessage(chat); + } + + @Override + @Transactional(readOnly = true) + public Page getChats(String chatRoomId, int pageNum) { + Pageable pageable = PageRequest.of(pageNum, PAGE_SIZE, Sort.by("timestamp").descending()); + Page chatPage = chatRepository.findByChatRoomId(chatRoomId, pageable); + + return chatPage.map(ChatConverter::fromEntity); + } +} diff --git a/src/main/java/org/example/plzdrawing/common/config/web/WebConfig.java b/src/main/java/org/example/plzdrawing/common/config/web/WebConfig.java index 14bb8a2..7324e13 100644 --- a/src/main/java/org/example/plzdrawing/common/config/web/WebConfig.java +++ b/src/main/java/org/example/plzdrawing/common/config/web/WebConfig.java @@ -1,12 +1,18 @@ package org.example.plzdrawing.common.config.web; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.util.security.interceptor.ChatRoomMembershipInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +@RequiredArgsConstructor @Configuration public class WebConfig implements WebMvcConfigurer { + private final ChatRoomMembershipInterceptor chatRoomMembershipInterceptor; + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") @@ -14,5 +20,10 @@ public void addCorsMappings(CorsRegistry registry) { .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") .allowCredentials(true); } -} -//TODO line13 추후 로드밸런서 ip로 수정..? + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(chatRoomMembershipInterceptor) + .addPathPatterns("/api/v1/chat/**"); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/plzdrawing/domain/chat/message/ChatRepository.java b/src/main/java/org/example/plzdrawing/domain/chat/message/ChatRepository.java new file mode 100644 index 0000000..57097f7 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/message/ChatRepository.java @@ -0,0 +1,10 @@ +package org.example.plzdrawing.domain.chat.message; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ChatRepository extends MongoRepository { + + Page findByChatRoomId(String chatRoomId, Pageable pageable); +} diff --git a/src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java b/src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java new file mode 100644 index 0000000..e126e4a --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java @@ -0,0 +1,5 @@ +package org.example.plzdrawing.domain.chat.message; + +public enum MessageType { + TEXT, FILE, IMAGE +} diff --git a/src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java b/src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java new file mode 100644 index 0000000..f83050e --- /dev/null +++ b/src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java @@ -0,0 +1,73 @@ +package org.example.plzdrawing.util.security.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.auth.customuser.CustomUser; +import org.example.plzdrawing.domain.chat.room.ChatRoom; +import org.example.plzdrawing.domain.chat.room.ChatRoomRepository; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class ChatRoomMembershipInterceptor implements HandlerInterceptor { + + private final ChatRoomRepository chatRoomRepository; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + String chatRoomId = extractChatRoomId(request, response); + if (chatRoomId == null) { + return false; + } + + CustomUser customUser = extractCustomUser(response); + if (customUser == null) { + return false; + } + Long memberId = customUser.getMemberId(); + + Optional chatRoom = chatRoomRepository.findById(chatRoomId); + if (chatRoom.isEmpty()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "채팅방이 존재하지 않습니다."); + return false; + } + + if (!isUserMemberOfChatRoom(memberId, chatRoom.get())) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."); + return false; + } + + // TODO: 캐시, 세션 등 최적화 + return true; + } + + private String extractChatRoomId(HttpServletRequest request, HttpServletResponse response) throws Exception { + String uri = request.getRequestURI(); + String[] parts = uri.split("/"); + if (parts.length < 4) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 요청입니다."); + return null; + } + return parts[3]; + } + + private CustomUser extractCustomUser(HttpServletResponse response) throws Exception { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof CustomUser customUser)) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "권한 정보가 잘못되었습니다."); + return null; + } + return customUser; + } + + private boolean isUserMemberOfChatRoom(Long memberId, ChatRoom chatRoom) { + return memberId.equals(chatRoom.getSellerId()) || memberId.equals(chatRoom.getBuyerId()); + } +} From 2f44077e4c8913fb7ed392581b378b22eb3b1ca7 Mon Sep 17 00:00:00 2001 From: beomzun Date: Fri, 28 Feb 2025 21:22:00 +0900 Subject: [PATCH 06/15] Chore: extract chat and chatroom domains into separate packages --- .../plzdrawing/api/chat/controller/ChatController.java | 2 +- .../plzdrawing/api/chat/dto/converter/ChatConverter.java | 2 +- .../example/plzdrawing/api/chat/dto/request/ChatDto.java | 2 +- .../plzdrawing/api/chat/dto/response/ResponseChat.java | 2 +- .../plzdrawing/api/chat/service/ChatServiceImpl.java | 4 ++-- .../api/chatRoom/dto/converter/ChatRoomConverter.java | 2 +- .../chatRoom/exception/ChatRoomAlreadyExistsException.java | 2 +- .../plzdrawing/api/chatRoom/service/ChatRoomService.java | 2 +- .../api/chatRoom/service/ChatRoomServiceImpl.java | 6 +++--- .../example/plzdrawing/domain/chat/{message => }/Chat.java | 2 +- .../domain/chat/{message => }/ChatRepository.java | 2 +- .../org/example/plzdrawing/domain/chat/MessageType.java | 5 +++++ .../example/plzdrawing/domain/chat/message/MessageType.java | 5 ----- .../plzdrawing/domain/{chat/room => chatroom}/ChatRoom.java | 2 +- .../domain/{chat/room => chatroom}/ChatRoomRepository.java | 2 +- .../security/interceptor/ChatRoomMembershipInterceptor.java | 4 ++-- 16 files changed, 23 insertions(+), 23 deletions(-) rename src/main/java/org/example/plzdrawing/domain/chat/{message => }/Chat.java (95%) rename src/main/java/org/example/plzdrawing/domain/chat/{message => }/ChatRepository.java (85%) create mode 100644 src/main/java/org/example/plzdrawing/domain/chat/MessageType.java delete mode 100644 src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java rename src/main/java/org/example/plzdrawing/domain/{chat/room => chatroom}/ChatRoom.java (96%) rename src/main/java/org/example/plzdrawing/domain/{chat/room => chatroom}/ChatRoomRepository.java (87%) diff --git a/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java index 5d9773e..25ba45b 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java +++ b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java @@ -39,5 +39,5 @@ public ResponseEntity> getChats(@PathVariable("chatRoomId") S return ResponseEntity.ok(chatPage); } - //TODO 정렬 인덱싱-날짜 / 이미지,파일 저장 및 조회 / 채팅전송 validation + //TODO 정렬 인덱싱-날짜 / 채팅전송 validation } diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java b/src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java index 49f9cae..f139544 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/converter/ChatConverter.java @@ -3,7 +3,7 @@ import java.sql.Timestamp; import org.example.plzdrawing.api.chat.dto.request.ChatDto; import org.example.plzdrawing.api.chat.dto.response.ResponseChat; -import org.example.plzdrawing.domain.chat.message.Chat; +import org.example.plzdrawing.domain.chat.Chat; public class ChatConverter { diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java b/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java index e8a508f..0b704a4 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java @@ -1,7 +1,7 @@ package org.example.plzdrawing.api.chat.dto.request; import lombok.Getter; -import org.example.plzdrawing.domain.chat.message.MessageType; +import org.example.plzdrawing.domain.chat.MessageType; @Getter public class ChatDto { diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java b/src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java index f6393a1..016d9ad 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/response/ResponseChat.java @@ -2,7 +2,7 @@ import java.sql.Timestamp; import lombok.AllArgsConstructor; -import org.example.plzdrawing.domain.chat.message.MessageType; +import org.example.plzdrawing.domain.chat.MessageType; @AllArgsConstructor public class ResponseChat { diff --git a/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java b/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java index 55abf70..725ba41 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java +++ b/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java @@ -6,8 +6,8 @@ import org.example.plzdrawing.api.chat.dto.response.ResponseChat; import org.example.plzdrawing.api.chat.dto.converter.ChatConverter; import org.example.plzdrawing.api.chatRoom.service.ChatRoomService; -import org.example.plzdrawing.domain.chat.message.Chat; -import org.example.plzdrawing.domain.chat.message.ChatRepository; +import org.example.plzdrawing.domain.chat.Chat; +import org.example.plzdrawing.domain.chat.ChatRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java index a9c8881..16eb736 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java @@ -1,7 +1,7 @@ package org.example.plzdrawing.api.chatRoom.dto.converter; import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; -import org.example.plzdrawing.domain.chat.room.ChatRoom; +import org.example.plzdrawing.domain.chatroom.ChatRoom; public class ChatRoomConverter { public static ResponseChatRoom fromEntity(ChatRoom chatRoom, String counterpartNickname) { diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java index f892db2..c75fd0d 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java @@ -2,7 +2,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.example.plzdrawing.domain.chat.room.ChatRoom; +import org.example.plzdrawing.domain.chatroom.ChatRoom; @Getter @RequiredArgsConstructor diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java index a5bc415..60d35bc 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java @@ -3,7 +3,7 @@ import java.util.List; import org.example.plzdrawing.api.chatRoom.dto.request.CreateChatRoomRequest; import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; -import org.example.plzdrawing.domain.chat.message.Chat; +import org.example.plzdrawing.domain.chat.Chat; public interface ChatRoomService { diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java index ea047e9..1dce217 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java @@ -15,9 +15,9 @@ import org.example.plzdrawing.api.chatRoom.exception.ChatRoomAlreadyExistsException; import org.example.plzdrawing.api.member.service.MemberService; import org.example.plzdrawing.common.exception.RestApiException; -import org.example.plzdrawing.domain.chat.message.Chat; -import org.example.plzdrawing.domain.chat.room.ChatRoom; -import org.example.plzdrawing.domain.chat.room.ChatRoomRepository; +import org.example.plzdrawing.domain.chat.Chat; +import org.example.plzdrawing.domain.chatroom.ChatRoom; +import org.example.plzdrawing.domain.chatroom.ChatRoomRepository; import org.example.plzdrawing.domain.member.Member; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/org/example/plzdrawing/domain/chat/message/Chat.java b/src/main/java/org/example/plzdrawing/domain/chat/Chat.java similarity index 95% rename from src/main/java/org/example/plzdrawing/domain/chat/message/Chat.java rename to src/main/java/org/example/plzdrawing/domain/chat/Chat.java index f1b3b71..35ace9e 100644 --- a/src/main/java/org/example/plzdrawing/domain/chat/message/Chat.java +++ b/src/main/java/org/example/plzdrawing/domain/chat/Chat.java @@ -1,4 +1,4 @@ -package org.example.plzdrawing.domain.chat.message; +package org.example.plzdrawing.domain.chat; import java.sql.Timestamp; import lombok.AccessLevel; diff --git a/src/main/java/org/example/plzdrawing/domain/chat/message/ChatRepository.java b/src/main/java/org/example/plzdrawing/domain/chat/ChatRepository.java similarity index 85% rename from src/main/java/org/example/plzdrawing/domain/chat/message/ChatRepository.java rename to src/main/java/org/example/plzdrawing/domain/chat/ChatRepository.java index 57097f7..d9cc38f 100644 --- a/src/main/java/org/example/plzdrawing/domain/chat/message/ChatRepository.java +++ b/src/main/java/org/example/plzdrawing/domain/chat/ChatRepository.java @@ -1,4 +1,4 @@ -package org.example.plzdrawing.domain.chat.message; +package org.example.plzdrawing.domain.chat; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java b/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java new file mode 100644 index 0000000..9aa2aff --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java @@ -0,0 +1,5 @@ +package org.example.plzdrawing.domain.chat; + +public enum MessageType { + TEXT, FILE, IMAGE +} diff --git a/src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java b/src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java deleted file mode 100644 index e126e4a..0000000 --- a/src/main/java/org/example/plzdrawing/domain/chat/message/MessageType.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.plzdrawing.domain.chat.message; - -public enum MessageType { - TEXT, FILE, IMAGE -} diff --git a/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoom.java b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoom.java similarity index 96% rename from src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoom.java rename to src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoom.java index 92ea152..cf074af 100644 --- a/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoom.java +++ b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoom.java @@ -1,4 +1,4 @@ -package org.example.plzdrawing.domain.chat.room; +package org.example.plzdrawing.domain.chatroom; import lombok.AccessLevel; import lombok.Builder; diff --git a/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoomRepository.java b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoomRepository.java similarity index 87% rename from src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoomRepository.java rename to src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoomRepository.java index 32b8dba..59d583a 100644 --- a/src/main/java/org/example/plzdrawing/domain/chat/room/ChatRoomRepository.java +++ b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoomRepository.java @@ -1,4 +1,4 @@ -package org.example.plzdrawing.domain.chat.room; +package org.example.plzdrawing.domain.chatroom; import java.util.List; import java.util.Optional; diff --git a/src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java b/src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java index f83050e..8e49159 100644 --- a/src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java +++ b/src/main/java/org/example/plzdrawing/util/security/interceptor/ChatRoomMembershipInterceptor.java @@ -5,8 +5,8 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.example.plzdrawing.api.auth.customuser.CustomUser; -import org.example.plzdrawing.domain.chat.room.ChatRoom; -import org.example.plzdrawing.domain.chat.room.ChatRoomRepository; +import org.example.plzdrawing.domain.chatroom.ChatRoom; +import org.example.plzdrawing.domain.chatroom.ChatRoomRepository; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; From 92a91d8ee1b27f94d964854ab9ab8d67714e7429 Mon Sep 17 00:00:00 2001 From: beomzun Date: Tue, 4 Mar 2025 17:25:23 +0900 Subject: [PATCH 07/15] Refactor: update @Component to @Repository to clearly indicate their role --- .../api/auth/repository/AuthCodeRedisRepository.java | 4 ++-- .../api/auth/repository/RefreshTokenRedisRepository.java | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java b/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java index 49ee6cd..fd0c3d8 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java +++ b/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java @@ -4,9 +4,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; -@Component +@Repository @RequiredArgsConstructor public class AuthCodeRedisRepository { diff --git a/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java b/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java index 3a95ecd..da19098 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java +++ b/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java @@ -4,12 +4,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; import org.springframework.stereotype.Repository; -@Component -@RequiredArgsConstructor @Repository +@RequiredArgsConstructor public class RefreshTokenRedisRepository { @Value("${jwt.refresh-expiration}") From 42da883ce372ffb556e3a0dcebd745b75cea5526 Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 18:59:56 +0900 Subject: [PATCH 08/15] Feat: file upload API based S3 --- build.gradle | 5 + .../api/file/controller/FileController.java | 31 +++++++ .../file/dto/response/FileUploadResponse.java | 11 +++ .../api/file/service/FileService.java | 10 ++ .../plzdrawing/common/config/s3/S3Config.java | 51 ++++++++++ .../plzdrawing/util/s3/dto/S3Component.java | 13 +++ .../util/s3/exception/S3ErrorCode.java | 18 ++++ .../plzdrawing/util/s3/service/S3Service.java | 93 +++++++++++++++++++ src/main/resources/application.yml | 11 +++ 9 files changed, 243 insertions(+) create mode 100644 src/main/java/org/example/plzdrawing/api/file/controller/FileController.java create mode 100644 src/main/java/org/example/plzdrawing/api/file/dto/response/FileUploadResponse.java create mode 100644 src/main/java/org/example/plzdrawing/api/file/service/FileService.java create mode 100644 src/main/java/org/example/plzdrawing/common/config/s3/S3Config.java create mode 100644 src/main/java/org/example/plzdrawing/util/s3/dto/S3Component.java create mode 100644 src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java create mode 100644 src/main/java/org/example/plzdrawing/util/s3/service/S3Service.java diff --git a/build.gradle b/build.gradle index 1c099ef..cc7a5f0 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,11 @@ dependencies { //chat implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + //S3 + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.3.0") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + } tasks.named('test') { diff --git a/src/main/java/org/example/plzdrawing/api/file/controller/FileController.java b/src/main/java/org/example/plzdrawing/api/file/controller/FileController.java new file mode 100644 index 0000000..c282d1f --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/file/controller/FileController.java @@ -0,0 +1,31 @@ +package org.example.plzdrawing.api.file.controller; + +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.file.dto.response.FileUploadResponse; +import org.example.plzdrawing.api.file.service.FileService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/files") +public class FileController { + + private final FileService fileService; + + @PostMapping("/upload") + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + String fileUrl = fileService.uploadFile(file); + FileUploadResponse response = new FileUploadResponse( + fileUrl, + file.getOriginalFilename(), + file.getSize(), + file.getContentType() + ); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/org/example/plzdrawing/api/file/dto/response/FileUploadResponse.java b/src/main/java/org/example/plzdrawing/api/file/dto/response/FileUploadResponse.java new file mode 100644 index 0000000..111619f --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/file/dto/response/FileUploadResponse.java @@ -0,0 +1,11 @@ +package org.example.plzdrawing.api.file.dto.response; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class FileUploadResponse { + private String fileUrl; + private String fileName; + private long fileSize; + private String mimeType; +} \ No newline at end of file diff --git a/src/main/java/org/example/plzdrawing/api/file/service/FileService.java b/src/main/java/org/example/plzdrawing/api/file/service/FileService.java new file mode 100644 index 0000000..5c3f138 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/file/service/FileService.java @@ -0,0 +1,10 @@ +package org.example.plzdrawing.api.file.service; + +import org.springframework.web.multipart.MultipartFile; + +public interface FileService { + + String uploadFile(MultipartFile file); + + void deleteFile(String fileUrl); +} \ No newline at end of file diff --git a/src/main/java/org/example/plzdrawing/common/config/s3/S3Config.java b/src/main/java/org/example/plzdrawing/common/config/s3/S3Config.java new file mode 100644 index 0000000..40aec8c --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/config/s3/S3Config.java @@ -0,0 +1,51 @@ +package org.example.plzdrawing.common.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.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AwsCredentialsProvider customAwsCredentialsProvider() { + return () -> new AwsCredentials() { + @Override + public String accessKeyId() { + return accessKey; + } + @Override + public String secretAccessKey() { + return secretKey; + } + }; + } + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .credentialsProvider(customAwsCredentialsProvider()) + .region(Region.of(region)) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .s3Client(s3Client()) + .build(); + } +} + diff --git a/src/main/java/org/example/plzdrawing/util/s3/dto/S3Component.java b/src/main/java/org/example/plzdrawing/util/s3/dto/S3Component.java new file mode 100644 index 0000000..9683061 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/util/s3/dto/S3Component.java @@ -0,0 +1,13 @@ +package org.example.plzdrawing.util.s3.dto; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Getter +@Component +public class S3Component { + + @Value("{spring.cloud.aws.s3.bucket}") + private String bucket; +} diff --git a/src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java b/src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java new file mode 100644 index 0000000..37eb82e --- /dev/null +++ b/src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java @@ -0,0 +1,18 @@ +package org.example.plzdrawing.util.s3.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.common.error.BaseErrorCode; +import org.example.plzdrawing.common.error.ErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum S3ErrorCode { //3 + UNPROCESSABLE_IMAGE(new BaseErrorCode(4223, HttpStatus.UNPROCESSABLE_ENTITY, "업로드된 이미지를 처리할 수 없습니다.")), + UPLOAD_FAILED(new BaseErrorCode(5001, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.")), + DELETE_FAILED(new BaseErrorCode(5002, HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다.")), + GET_URL_FAILED(new BaseErrorCode(5003, HttpStatus.INTERNAL_SERVER_ERROR, "파일 URL 발급에 실패했습니다.")); + + private final ErrorCode errorCode; +} \ No newline at end of file diff --git a/src/main/java/org/example/plzdrawing/util/s3/service/S3Service.java b/src/main/java/org/example/plzdrawing/util/s3/service/S3Service.java new file mode 100644 index 0000000..aa19358 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/util/s3/service/S3Service.java @@ -0,0 +1,93 @@ +package org.example.plzdrawing.util.s3.service; + +import java.io.IOException; +import java.net.URL; +import java.time.Duration; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.common.exception.RestApiException; +import org.example.plzdrawing.util.s3.dto.S3Component; +import org.example.plzdrawing.api.file.service.FileService; +import org.example.plzdrawing.util.s3.exception.S3ErrorCode; +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.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +@Service +@RequiredArgsConstructor +public class S3Service implements FileService { + private final S3Client s3Client; + private final S3Component s3Component; + private final S3Presigner s3Presigner; + + @Value("${spring.cloud.aws.s3.url-duration}") + private long presignedUrlDurationMinutes; + + @Override + public String uploadFile(MultipartFile file) { + String fileName = generateUniqueFileName(file.getOriginalFilename()); + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Component.getBucket()) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .key(fileName) + .build(); + RequestBody requestBody = RequestBody.fromBytes(file.getBytes()); + + s3Client.putObject(putObjectRequest, requestBody); + } catch (IOException e) { + throw new RestApiException(S3ErrorCode.UNPROCESSABLE_IMAGE.getErrorCode()); + } catch (S3Exception e) { + throw new RestApiException(S3ErrorCode.UPLOAD_FAILED.getErrorCode()); + } + + return getFileUrl(fileName); + } + + @Override + public void deleteFile(String fileName) { + try { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(s3Component.getBucket()) + .key(fileName) + .build(); + + s3Client.deleteObject(deleteObjectRequest); + } catch (S3Exception e) { + throw new RestApiException(S3ErrorCode.DELETE_FAILED.getErrorCode()); + } + } + + private String getFileUrl(String fileName) { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(s3Component.getBucket()) + .key(fileName) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(presignedUrlDurationMinutes)) + .getObjectRequest(getObjectRequest) + .build(); + + URL presignedUrl = s3Presigner.presignGetObject(presignRequest).url(); + return presignedUrl.toString(); + } catch (S3Exception e) { + throw new RestApiException(S3ErrorCode.GET_URL_FAILED.getErrorCode()); + } + } + + private String generateUniqueFileName(String originalFileName) { + return UUID.randomUUID() + "-" + originalFileName; + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bb2ff6a..1c4aa88 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,6 +37,17 @@ spring: writetimeout: 5000 auth-code-expiration: ${AUTH_CODE_EXPIRATION} + #S3 + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ${BUCKET_REGION} + s3: + bucket: ${BUCKET_NAME} + url-duration: ${PRESIGNED_URL_DURATION} #Swagger springdoc: From 00cfc06b5a3e328ea29727637617b04759ade2d1 Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 19:06:58 +0900 Subject: [PATCH 09/15] Refactor: switch from StringRedisTemplate to generic RedisTemplate --- .../repository/AuthCodeRedisRepository.java | 12 ++++++----- .../RefreshTokenRedisRepository.java | 4 ++-- .../common/config/redis/RedisConfig.java | 20 +++++++++++++++---- .../common/redis/RedisKeyPrefix.java | 10 ++++++++++ 4 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/example/plzdrawing/common/redis/RedisKeyPrefix.java diff --git a/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java b/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java index fd0c3d8..a644d02 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java +++ b/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java @@ -1,9 +1,11 @@ package org.example.plzdrawing.api.auth.repository; +import static org.example.plzdrawing.common.redis.RedisKeyPrefix.EMAIL_AUTH_NUMBER; + import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; @Repository @@ -13,15 +15,15 @@ public class AuthCodeRedisRepository { @Value("${spring.mail.auth-code-expiration}") private Long EXPIRATION; - private final StringRedisTemplate redisTemplate; - private final String PREFIX = "AuthNumber:"; + private final RedisTemplate redisTemplate; public void saveAuthNumber(String key, String emailAuthNumber) { - redisTemplate.opsForValue().set(PREFIX+key, emailAuthNumber, EXPIRATION, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue() + .set(EMAIL_AUTH_NUMBER + key, emailAuthNumber, EXPIRATION, TimeUnit.MILLISECONDS); } public String findEmailAuthNumberByKey(String key) { - return redisTemplate.opsForValue().get(PREFIX + key); + return redisTemplate.opsForValue().get(EMAIL_AUTH_NUMBER + key); } } diff --git a/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java b/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java index da19098..8a38ec8 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java +++ b/src/main/java/org/example/plzdrawing/api/auth/repository/RefreshTokenRedisRepository.java @@ -3,7 +3,7 @@ import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; @Repository @@ -13,7 +13,7 @@ public class RefreshTokenRedisRepository { @Value("${jwt.refresh-expiration}") private Long EXPIRATION; - private final StringRedisTemplate redisTemplate; + private final RedisTemplate redisTemplate; public void saveRefreshToken(String memberId, String jti, String refreshToken) { String redisId = createRedisId(memberId, jti); diff --git a/src/main/java/org/example/plzdrawing/common/config/redis/RedisConfig.java b/src/main/java/org/example/plzdrawing/common/config/redis/RedisConfig.java index 5b3407c..41e53ca 100644 --- a/src/main/java/org/example/plzdrawing/common/config/redis/RedisConfig.java +++ b/src/main/java/org/example/plzdrawing/common/config/redis/RedisConfig.java @@ -6,8 +6,10 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @EnableRedisRepositories @@ -31,9 +33,19 @@ public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(HOST, PORT); return new LettuceConnectionFactory(config); } - @Bean - public StringRedisTemplate redisTemplate() { - return new StringRedisTemplate(redisConnectionFactory()); + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; } } diff --git a/src/main/java/org/example/plzdrawing/common/redis/RedisKeyPrefix.java b/src/main/java/org/example/plzdrawing/common/redis/RedisKeyPrefix.java new file mode 100644 index 0000000..62167f3 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/redis/RedisKeyPrefix.java @@ -0,0 +1,10 @@ +package org.example.plzdrawing.common.redis; + +public class RedisKeyPrefix { + + public static final String CHATROOM_PRESENCE = "chatroom:presence:"; + public static final String CHATROOM_UNREAD = "chatroom:unread:"; + public static final String EMAIL_AUTH_NUMBER = "AuthNumber:"; + + private RedisKeyPrefix() {} +} From cca545412ecbf34ad4918e54b64e93d91124a239 Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 19:22:58 +0900 Subject: [PATCH 10/15] Feat: add isRead in Chat to present a chat has been read And store unreadCount --- .../controller/ChatRoomController.java | 2 -- .../dto/converter/ChatRoomConverter.java | 5 +-- .../dto/response/ResponseChatRoom.java | 1 + .../repository/UnreadCountRepository.java | 34 +++++++++++++++++++ .../chatRoom/service/ChatRoomServiceImpl.java | 8 +++-- .../example/plzdrawing/domain/chat/Chat.java | 10 ++++++ .../plzdrawing/domain/chat/MessageType.java | 16 ++++++++- 7 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/example/plzdrawing/api/chatRoom/repository/UnreadCountRepository.java diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java b/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java index 0d7f2ab..ea68650 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java @@ -37,6 +37,4 @@ public ResponseEntity> getChatRooms(@AuthenticationPrinci List chatRooms = chatRoomService.getChatRooms(customUser.getMemberId()); return ResponseEntity.ok(chatRooms); } - - //TODO 읽지 않은 채팅 개수 } diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java index 16eb736..d8b94b9 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java @@ -4,11 +4,12 @@ import org.example.plzdrawing.domain.chatroom.ChatRoom; public class ChatRoomConverter { - public static ResponseChatRoom fromEntity(ChatRoom chatRoom, String counterpartNickname) { + public static ResponseChatRoom fromEntity(ChatRoom chatRoom, String counterpartNickname, int unreadCount) { return new ResponseChatRoom( chatRoom.getChatRoomId(), counterpartNickname, - chatRoom.getLastMessage() + chatRoom.getLastMessage(), + unreadCount ); } } diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java index 633f832..237086c 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java @@ -8,4 +8,5 @@ public class ResponseChatRoom { private String chatRoomId; private String counterpartNickname; private String lastMessage; + private int unreadCount; } diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/repository/UnreadCountRepository.java b/src/main/java/org/example/plzdrawing/api/chatRoom/repository/UnreadCountRepository.java new file mode 100644 index 0000000..c881d2f --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/repository/UnreadCountRepository.java @@ -0,0 +1,34 @@ +package org.example.plzdrawing.api.chatRoom.repository; + +import static org.example.plzdrawing.common.redis.RedisKeyPrefix.*; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UnreadCountRepository { + + private final RedisTemplate redisTemplate; + + public void incrementUnreadCount(String chatRoomId, Long memberId) { + String key = getRedisKey(chatRoomId, memberId); + redisTemplate.opsForValue().increment(key, 1); + } + + public int getUnreadCount(String chatRoomId, Long memberId) { + String key = getRedisKey(chatRoomId, memberId); + return redisTemplate.opsForValue().get(key); + } + + public void resetUnreadCount(String chatRoomId, Long memberId) { + String key = getRedisKey(chatRoomId, memberId); + redisTemplate.opsForValue().set(key, 0); + } + + private String getRedisKey(String chatRoomId, Long memberId) { + return CHATROOM_UNREAD + chatRoomId + ":" + memberId; + } +} + diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java index 1dce217..376ce4a 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java @@ -13,6 +13,7 @@ import org.example.plzdrawing.api.chatRoom.dto.request.CreateChatRoomRequest; import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; import org.example.plzdrawing.api.chatRoom.exception.ChatRoomAlreadyExistsException; +import org.example.plzdrawing.api.chatRoom.repository.UnreadCountRepository; import org.example.plzdrawing.api.member.service.MemberService; import org.example.plzdrawing.common.exception.RestApiException; import org.example.plzdrawing.domain.chat.Chat; @@ -28,6 +29,8 @@ public class ChatRoomServiceImpl implements ChatRoomService { private final MemberService memberService; private final ChatRoomRepository chatRoomRepository; + private final UnreadCountRepository unreadCountRepository; + @Override @Transactional public String createChatRoom(Long memberId, CreateChatRoomRequest request) { @@ -45,7 +48,7 @@ public String createChatRoom(Long memberId, CreateChatRoomRequest request) { public void updateLastMessage(Chat chat) { ChatRoom chatRoom = chatRoomRepository.findById(chat.getChatRoomId()) .orElseThrow(() -> new RestApiException(CHATROOM_NOT_FOUND.getErrorCode())); - chatRoom.updateLastMessage(chat.getMessage()); + chatRoom.updateLastMessage(chat.getDisplayContent()); } @Override @@ -55,7 +58,8 @@ public List getChatRooms(Long memberId) { return chatRooms.stream() .map(chatRoom -> { String counterpartNickname = pickCounterpartNickname(memberId,chatRoom); - return ChatRoomConverter.fromEntity(chatRoom, counterpartNickname); + int unreadCount = unreadCountRepository.getUnreadCount(chatRoom.getChatRoomId(), memberId); + return ChatRoomConverter.fromEntity(chatRoom, counterpartNickname, unreadCount); }) .collect(Collectors.toList()); } diff --git a/src/main/java/org/example/plzdrawing/domain/chat/Chat.java b/src/main/java/org/example/plzdrawing/domain/chat/Chat.java index 35ace9e..3888c3e 100644 --- a/src/main/java/org/example/plzdrawing/domain/chat/Chat.java +++ b/src/main/java/org/example/plzdrawing/domain/chat/Chat.java @@ -21,6 +21,7 @@ public class Chat { private Long senderId; private String message; private MessageType messageType; + private boolean isRead; private String fileUrl; private String fileName; @@ -37,10 +38,19 @@ private Chat(String chatRoomId, Long senderId, String message, MessageType messa this.senderId = senderId; this.message = message; this.messageType = messageType; + this.isRead = true; this.fileUrl = fileUrl; this.fileName = fileName; this.fileSize = fileSize; this.mimeType = mimeType; this.timestamp = timestamp; } + + public String getDisplayContent() { + return messageType.getDisplayContent(message); + } + + public void read(boolean b) { + isRead = b; + } } diff --git a/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java b/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java index 9aa2aff..fa14425 100644 --- a/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java +++ b/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java @@ -1,5 +1,19 @@ package org.example.plzdrawing.domain.chat; public enum MessageType { - TEXT, FILE, IMAGE + TEXT { + @Override + public String getDisplayContent(String message) { + return message; + } + }, + FILE { + @Override + public String getDisplayContent(String message) { + return "파일이 전송되었습니다."; + } + } + ; + + public abstract String getDisplayContent(String message); } From 403017cdd4d21111a8fdd014a6930562d0b1e15c Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 19:27:12 +0900 Subject: [PATCH 11/15] Feat: add additional validation for file message --- .../api/chat/controller/ChatController.java | 11 ++--- .../api/chat/dto/request/ChatDto.java | 11 +++++ .../chat/dto/validation/ChatDtoValidator.java | 47 +++++++++++++++++++ .../api/chat/dto/validation/ValidChatDto.java | 20 ++++++++ 4 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/example/plzdrawing/api/chat/dto/validation/ChatDtoValidator.java create mode 100644 src/main/java/org/example/plzdrawing/api/chat/dto/validation/ValidChatDto.java diff --git a/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java index 25ba45b..197b463 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java +++ b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java @@ -1,5 +1,6 @@ package org.example.plzdrawing.api.chat.controller; +import jakarta.validation.Valid; import java.sql.Timestamp; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; @@ -10,7 +11,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -22,13 +22,11 @@ @RequestMapping("/api/v1/chat") public class ChatController { - private final SimpMessagingTemplate messagingTemplate; private final ChatService chatService; @MessageMapping("/v1/chat") //app/v1/chat - public void sendMessage(@Payload ChatDto chatDto) { + public void sendMessage(@Payload @Valid ChatDto chatDto) { chatService.saveMessage(chatDto, Timestamp.valueOf(LocalDateTime.now())); - messagingTemplate.convertAndSend("/topic/"+chatDto.getChatRoomId(), chatDto); } @GetMapping("/{chatRoomId}") @@ -36,8 +34,7 @@ public ResponseEntity> getChats(@PathVariable("chatRoomId") S @RequestParam(name = "pageNum", defaultValue = "0") int pageNum) { Page chatPage = chatService.getChats(chatRoomId, pageNum); + //TODO 검증 1회만 return ResponseEntity.ok(chatPage); } - - //TODO 정렬 인덱싱-날짜 / 채팅전송 validation -} +} \ No newline at end of file diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java b/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java index 0b704a4..77c4c55 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java @@ -1,14 +1,25 @@ package org.example.plzdrawing.api.chat.dto.request; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; import lombok.Getter; +import org.example.plzdrawing.api.chat.dto.validation.ValidChatDto; import org.example.plzdrawing.domain.chat.MessageType; @Getter +@ValidChatDto public class ChatDto { + @Positive private String chatRoomId; + + @Positive private Long senderId; + + @NotBlank private String message; + + @NotBlank private MessageType messageType; private String fileUrl; diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/validation/ChatDtoValidator.java b/src/main/java/org/example/plzdrawing/api/chat/dto/validation/ChatDtoValidator.java new file mode 100644 index 0000000..9816c24 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/validation/ChatDtoValidator.java @@ -0,0 +1,47 @@ +package org.example.plzdrawing.api.chat.dto.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.example.plzdrawing.api.chat.dto.request.ChatDto; +import org.example.plzdrawing.domain.chat.MessageType; + +public class ChatDtoValidator implements ConstraintValidator { + + @Override + public boolean isValid(ChatDto chatDto, ConstraintValidatorContext context) { + if (chatDto.getMessageType() == MessageType.TEXT) { + return true; + } + + boolean valid = true; + context.disableDefaultConstraintViolation(); + + if (isEmpty(chatDto.getFileUrl())) { + addViolation(context, "fileUrl", "파일 전송 시 fileUrl은 필수입니다."); + valid = false; + } + if (isEmpty(chatDto.getFileName())) { + addViolation(context, "fileName", "파일 전송 시 fileName은 필수입니다."); + valid = false; + } + if (chatDto.getFileSize() == null || chatDto.getFileSize() <= 0) { + addViolation(context, "fileSize", "파일 전송 시 fileSize는 필수입니다."); + valid = false; + } + if (isEmpty(chatDto.getMimeType())) { + addViolation(context, "mimeType", "파일 전송 시 mimeType은 필수입니다."); + valid = false; + } + return valid; + } + + private boolean isEmpty(String value) { + return value == null || value.trim().isEmpty(); + } + + private void addViolation(ConstraintValidatorContext context, String property, String message) { + context.buildConstraintViolationWithTemplate(message) + .addPropertyNode(property) + .addConstraintViolation(); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/plzdrawing/api/chat/dto/validation/ValidChatDto.java b/src/main/java/org/example/plzdrawing/api/chat/dto/validation/ValidChatDto.java new file mode 100644 index 0000000..48181bf --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/validation/ValidChatDto.java @@ -0,0 +1,20 @@ +package org.example.plzdrawing.api.chat.dto.validation; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = ChatDtoValidator.class) +@Target({ElementType.TYPE}) +@Retention(RUNTIME) +public @interface ValidChatDto { + String message() default "잘못된 요청입니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} \ No newline at end of file From 4cb6968be6e7653125aaf4f6b7adf3a7f26c5147 Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 21:04:26 +0900 Subject: [PATCH 12/15] Feat: implement recipient state-based logic when sending message --- .../repository/ChatPresenceRepository.java | 32 +++++++++++++++++++ .../api/chat/service/ChatServiceImpl.java | 26 +++++++++++++++ .../api/chatRoom/service/ChatRoomService.java | 3 ++ .../chatRoom/service/ChatRoomServiceImpl.java | 6 +++- .../listener/websocket/dto/JoinEvent.java | 9 ++++++ .../util/websocket/MessagePublisher.java | 25 +++++++++++++++ 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/example/plzdrawing/api/chat/repository/ChatPresenceRepository.java create mode 100644 src/main/java/org/example/plzdrawing/common/listener/websocket/dto/JoinEvent.java create mode 100644 src/main/java/org/example/plzdrawing/util/websocket/MessagePublisher.java diff --git a/src/main/java/org/example/plzdrawing/api/chat/repository/ChatPresenceRepository.java b/src/main/java/org/example/plzdrawing/api/chat/repository/ChatPresenceRepository.java new file mode 100644 index 0000000..5226731 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/repository/ChatPresenceRepository.java @@ -0,0 +1,32 @@ +package org.example.plzdrawing.api.chat.repository; + +import static org.example.plzdrawing.common.redis.RedisKeyPrefix.CHATROOM_PRESENCE; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ChatPresenceRepository { + + private final RedisTemplate redisTemplate; + + public void setActive(String chatRoomId, Long memberId) { + redisTemplate.opsForValue().set(getKey(memberId), chatRoomId); + } + + public void removeActive(Long memberId) { + redisTemplate.delete(getKey(memberId)); + } + + public boolean isActive(String chatRoomId, Long memberId) { + String key = getKey(memberId); + return redisTemplate.hasKey(key) + && redisTemplate.opsForValue().get(key).equals(chatRoomId); + } + + private String getKey(Long memberId) { + return CHATROOM_PRESENCE + memberId; + } +} diff --git a/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java b/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java index 725ba41..74a408a 100644 --- a/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java +++ b/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java @@ -5,9 +5,13 @@ import org.example.plzdrawing.api.chat.dto.request.ChatDto; import org.example.plzdrawing.api.chat.dto.response.ResponseChat; import org.example.plzdrawing.api.chat.dto.converter.ChatConverter; +import org.example.plzdrawing.api.chat.repository.ChatPresenceRepository; +import org.example.plzdrawing.api.chatRoom.repository.UnreadCountRepository; import org.example.plzdrawing.api.chatRoom.service.ChatRoomService; import org.example.plzdrawing.domain.chat.Chat; import org.example.plzdrawing.domain.chat.ChatRepository; +import org.example.plzdrawing.domain.chatroom.ChatRoom; +import org.example.plzdrawing.util.websocket.MessagePublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -21,12 +25,27 @@ public class ChatServiceImpl implements ChatService { private final ChatRepository chatRepository; private final ChatRoomService chatRoomService; + private final UnreadCountRepository unreadCountRepository; + private final ChatPresenceRepository chatPresenceRepository; + private final MessagePublisher messagePublisher; private static final int PAGE_SIZE = 20; @Override @Transactional public void saveMessage(ChatDto chatDto, Timestamp sendTime) { Chat chat = ChatConverter.toEntity(chatDto, sendTime); + ChatRoom chatRoom = chatRoomService.findById(chat.getChatRoomId()); + String chatRoomId = chatRoom.getChatRoomId(); + Long recipientId = extractRecipientId(chat, chatRoom); + + if (!chatPresenceRepository.isActive(chatRoomId, recipientId)) { + unreadCountRepository.incrementUnreadCount(chatRoomId, recipientId); + chat.read(false); + messagePublisher.publishNotExist(chatRoomId, chat); + } else { + messagePublisher.publishExist(chatRoomId, chat); + } + chatRepository.save(chat); chatRoomService.updateLastMessage(chat); } @@ -39,4 +58,11 @@ public Page getChats(String chatRoomId, int pageNum) { return chatPage.map(ChatConverter::fromEntity); } + + private Long extractRecipientId(Chat chat, ChatRoom chatRoom) { + if (chat.getSenderId().equals(chatRoom.getSellerId())) { + return chatRoom.getBuyerId(); + } + return chatRoom.getSellerId(); + } } diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java index 60d35bc..955226d 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java @@ -4,6 +4,7 @@ import org.example.plzdrawing.api.chatRoom.dto.request.CreateChatRoomRequest; import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; import org.example.plzdrawing.domain.chat.Chat; +import org.example.plzdrawing.domain.chatroom.ChatRoom; public interface ChatRoomService { @@ -12,4 +13,6 @@ public interface ChatRoomService { String createChatRoom(Long memberId, CreateChatRoomRequest request); void updateLastMessage(Chat chat); + + ChatRoom findById(String chatRoomId); } diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java index 376ce4a..9c46f3c 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java @@ -64,7 +64,11 @@ public List getChatRooms(Long memberId) { .collect(Collectors.toList()); } - + @Transactional(readOnly = true) + public ChatRoom findById(String chatRoomId) { + return chatRoomRepository.findById(chatRoomId) + .orElseThrow(() -> new RestApiException(CHATROOM_NOT_FOUND.getErrorCode())); + } private String pickCounterpartNickname(Long memberId, ChatRoom chatRoom) { if(Objects.equals(memberId, chatRoom.getSellerId())) { diff --git a/src/main/java/org/example/plzdrawing/common/listener/websocket/dto/JoinEvent.java b/src/main/java/org/example/plzdrawing/common/listener/websocket/dto/JoinEvent.java new file mode 100644 index 0000000..e7e0894 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/listener/websocket/dto/JoinEvent.java @@ -0,0 +1,9 @@ +package org.example.plzdrawing.common.listener.websocket.dto; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class JoinEvent { + private String chatRoomId; + private Long memberId; +} diff --git a/src/main/java/org/example/plzdrawing/util/websocket/MessagePublisher.java b/src/main/java/org/example/plzdrawing/util/websocket/MessagePublisher.java new file mode 100644 index 0000000..d79ed93 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/util/websocket/MessagePublisher.java @@ -0,0 +1,25 @@ +package org.example.plzdrawing.util.websocket; + +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.common.listener.websocket.dto.JoinEvent; +import org.example.plzdrawing.domain.chat.Chat; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MessagePublisher { + + private final SimpMessagingTemplate messagingTemplate; + + public void publishNotExist(String chatRoomId, Chat chat) { + messagingTemplate.convertAndSend("/topic/unread/"+chatRoomId, chat.getDisplayContent()); + } + public void publishExist(String chatRoomId, Chat chat) { + messagingTemplate.convertAndSend("/topic/join/" + chatRoomId, chat); + } + public void removeUnreadCheck(String chatRoomId, Long memberId) { + JoinEvent joinEvent = new JoinEvent(chatRoomId, memberId); + messagingTemplate.convertAndSend("/topic/join/" + chatRoomId, joinEvent); + } +} From efc657e23e65db8a75a11345a6dff885eaa13702 Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 21:07:18 +0900 Subject: [PATCH 13/15] Feat: implement event listeners for subscribe, unsubscribe, disconnect events for dealing user's state in session --- .../ChatDisconnectEventListener.java | 22 ++++++++++++ .../ChatSubscriptionEventListener.java | 34 +++++++++++++++++++ .../ChatUnsubscribeEventListener.java | 25 ++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 src/main/java/org/example/plzdrawing/common/listener/websocket/ChatDisconnectEventListener.java create mode 100644 src/main/java/org/example/plzdrawing/common/listener/websocket/ChatSubscriptionEventListener.java create mode 100644 src/main/java/org/example/plzdrawing/common/listener/websocket/ChatUnsubscribeEventListener.java diff --git a/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatDisconnectEventListener.java b/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatDisconnectEventListener.java new file mode 100644 index 0000000..aa20be1 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatDisconnectEventListener.java @@ -0,0 +1,22 @@ +package org.example.plzdrawing.common.listener.websocket; + +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.chat.repository.ChatPresenceRepository; +import org.springframework.context.ApplicationListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +@Component +@RequiredArgsConstructor +public class ChatDisconnectEventListener implements ApplicationListener { + + private final ChatPresenceRepository chatPresenceRepository; + + @Override + public void onApplicationEvent(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + Long memberId = Long.parseLong(accessor.getFirstNativeHeader("memberId")); + chatPresenceRepository.removeActive(memberId); + } +} diff --git a/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatSubscriptionEventListener.java b/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatSubscriptionEventListener.java new file mode 100644 index 0000000..2bd5302 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatSubscriptionEventListener.java @@ -0,0 +1,34 @@ +package org.example.plzdrawing.common.listener.websocket; + +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.chat.repository.ChatPresenceRepository; +import org.example.plzdrawing.api.chatRoom.repository.UnreadCountRepository; +import org.example.plzdrawing.util.websocket.MessagePublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +@Component +@RequiredArgsConstructor +public class ChatSubscriptionEventListener implements ApplicationListener { + + private final ChatPresenceRepository chatPresenceRepository; + private final UnreadCountRepository unreadCountRepository; + private final MessagePublisher messagePublisher; + + @Override + public void onApplicationEvent(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + if (destination.startsWith("/topic/join/")) { + String chatRoomId = destination.substring("/topic/join/".length()); + + Long memberId = Long.parseLong(accessor.getFirstNativeHeader("memberId")); + chatPresenceRepository.setActive(chatRoomId, memberId); + unreadCountRepository.resetUnreadCount(chatRoomId, memberId); + + messagePublisher.removeUnreadCheck(chatRoomId, memberId); + } + } +} diff --git a/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatUnsubscribeEventListener.java b/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatUnsubscribeEventListener.java new file mode 100644 index 0000000..11ebf75 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/listener/websocket/ChatUnsubscribeEventListener.java @@ -0,0 +1,25 @@ +package org.example.plzdrawing.common.listener.websocket; + +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.chat.repository.ChatPresenceRepository; +import org.springframework.context.ApplicationListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +@Component +@RequiredArgsConstructor +public class ChatUnsubscribeEventListener implements ApplicationListener { + + private final ChatPresenceRepository chatPresenceRepository; + + @Override + public void onApplicationEvent(SessionUnsubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + if (destination.startsWith("/topic/join/")) { + Long memberId = Long.parseLong(accessor.getFirstNativeHeader("memberId")); + chatPresenceRepository.removeActive(memberId); + } + } +} \ No newline at end of file From 9d656936839a40e56ddb5abd15bdddf3622d055a Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 22:27:21 +0900 Subject: [PATCH 14/15] . --- .../exception/GlobalExceptionHandler.java | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java diff --git a/src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java b/src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java deleted file mode 100644 index f7b4318..0000000 --- a/src/main/java/org/example/plzdrawing/common/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.example.plzdrawing.common.exception; - -import java.util.List; -import java.util.stream.Collectors; -import lombok.NonNull; -import org.example.plzdrawing.common.error.CommonErrorCode; -import org.example.plzdrawing.common.error.ErrorResponse; -import org.example.plzdrawing.common.error.ErrorResponse.ValidationError; -import org.example.plzdrawing.common.error.ErrorCode; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.BindException; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -@RestControllerAdvice -public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { - - @ExceptionHandler(RestApiException.class) - public ResponseEntity handleCustomException(RestApiException e) { - ErrorCode errorCode = e.getErrorCode(); - return handleExceptionInternal(errorCode); - } - - private ResponseEntity handleExceptionInternal(ErrorCode errorCode) { - return ResponseEntity.status(errorCode.getHttpStatus()) - .body(makeErrorResponse(errorCode)); - } - - private ErrorResponse makeErrorResponse(ErrorCode errorCode) { - return ErrorResponse.builder() - .code(errorCode.getCode()) - .message(errorCode.getMessage()) - .build(); - } - - @Override - public ResponseEntity handleMethodArgumentNotValid( - @NonNull MethodArgumentNotValidException e, - @NonNull HttpHeaders headers, - @NonNull HttpStatusCode status, - @NonNull WebRequest request - ) { - ErrorCode errorCode = CommonErrorCode.BAD_REQUEST.getErrorCode(); - return handleExceptionInternal(e, errorCode); - } - - private ResponseEntity handleExceptionInternal(BindException e, ErrorCode errorCode) { - return ResponseEntity.status(errorCode.getHttpStatus()) - .body(makeErrorResponse(e, errorCode)); - } - - private ErrorResponse makeErrorResponse(BindException e, ErrorCode errorCode) { - List validationErrorList = e.getBindingResult() - .getFieldErrors() - .stream() - .map(ErrorResponse.ValidationError::of) - .collect(Collectors.toList()); - - return ErrorResponse.builder() - .code(errorCode.getCode()) - .message(errorCode.getMessage()) - .errors(validationErrorList) - .build(); - } -} - From bc0bdc82b5d2a58d1627e5346f1db9d34946cfab Mon Sep 17 00:00:00 2001 From: beomzun Date: Sun, 23 Mar 2025 22:31:48 +0900 Subject: [PATCH 15/15] Fix: ErrorCode type int to String --- .../api/auth/service/strategy/EmailService.java | 4 ++-- .../api/auth/service/strategy/email/EmailServiceImpl.java | 4 ++-- .../api/chatRoom/exception/ChatRoomErrorCode.java | 4 ++-- .../example/plzdrawing/util/s3/exception/S3ErrorCode.java | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java index 8ede6cc..f7395fd 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java +++ b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java @@ -33,8 +33,8 @@ public LoginResponse login(LoginRequest request) { validatePassword(request, member); - String accessToken = tokenService.createAccessToken(String.valueOf(member.getId())); - String refreshToken = tokenService.createRefreshToken(String.valueOf(member.getId())); + String accessToken = tokenService.createAccessToken(member.getId()); + String refreshToken = tokenService.createRefreshToken(member.getId()); return new LoginResponse(accessToken, refreshToken); } diff --git a/src/main/java/org/example/plzdrawing/api/auth/service/strategy/email/EmailServiceImpl.java b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/email/EmailServiceImpl.java index c230597..604cde4 100644 --- a/src/main/java/org/example/plzdrawing/api/auth/service/strategy/email/EmailServiceImpl.java +++ b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/email/EmailServiceImpl.java @@ -39,8 +39,8 @@ public LoginResponse login(LoginRequest request) { validatePassword(request, member); - String accessToken = tokenService.createAccessToken(String.valueOf(member.getId())); - String refreshToken = tokenService.createRefreshToken(String.valueOf(member.getId())); + String accessToken = tokenService.createAccessToken(member.getId()); + String refreshToken = tokenService.createRefreshToken(member.getId()); return new LoginResponse(accessToken, refreshToken); } diff --git a/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java index 7691336..57e3e09 100644 --- a/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomErrorCode.java @@ -8,8 +8,8 @@ @Getter @RequiredArgsConstructor -public enum ChatRoomErrorCode {//2 - CHATROOM_NOT_FOUND(new BaseErrorCode(4042, HttpStatus.NOT_FOUND,"존재하지 않는 채팅방입니다")) +public enum ChatRoomErrorCode { + CHATROOM_NOT_FOUND(new BaseErrorCode("CHATROOM_001", HttpStatus.NOT_FOUND,"존재하지 않는 채팅방입니다")) ; private final ErrorCode errorCode; diff --git a/src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java b/src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java index 37eb82e..38faf0d 100644 --- a/src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java +++ b/src/main/java/org/example/plzdrawing/util/s3/exception/S3ErrorCode.java @@ -9,10 +9,10 @@ @Getter @RequiredArgsConstructor public enum S3ErrorCode { //3 - UNPROCESSABLE_IMAGE(new BaseErrorCode(4223, HttpStatus.UNPROCESSABLE_ENTITY, "업로드된 이미지를 처리할 수 없습니다.")), - UPLOAD_FAILED(new BaseErrorCode(5001, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.")), - DELETE_FAILED(new BaseErrorCode(5002, HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다.")), - GET_URL_FAILED(new BaseErrorCode(5003, HttpStatus.INTERNAL_SERVER_ERROR, "파일 URL 발급에 실패했습니다.")); + UNPROCESSABLE_IMAGE(new BaseErrorCode("S3_001", HttpStatus.UNPROCESSABLE_ENTITY, "업로드된 이미지를 처리할 수 없습니다.")), + UPLOAD_FAILED(new BaseErrorCode("S3_002", HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.")), + DELETE_FAILED(new BaseErrorCode("S3_003", HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다.")), + GET_URL_FAILED(new BaseErrorCode("S3_004", HttpStatus.INTERNAL_SERVER_ERROR, "파일 URL 발급에 실패했습니다.")); private final ErrorCode errorCode; } \ No newline at end of file