diff --git a/build.gradle b/build.gradle index db9fd77..a81b9f5 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,15 @@ 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' + + //S3 + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.3.0") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + } test { 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/repository/AuthCodeRedisRepository.java b/src/main/java/org/example/plzdrawing/api/auth/repository/AuthCodeRedisRepository.java index d6f5905..8e8c448 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,28 +1,29 @@ package org.example.plzdrawing.api.auth.repository; +import static org.example.plzdrawing.common.redis.RedisKeyPrefix.EMAIL_AUTH_NUMBER; +import static org.example.plzdrawing.common.redis.RedisKeyPrefix.REISSUE_PREFIX; + 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.stereotype.Component; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; -@Component +@Repository @RequiredArgsConstructor public class AuthCodeRedisRepository { @Value("${spring.mail.auth-code-expiration}") private Long EXPIRATION; - private final StringRedisTemplate redisTemplate; - private final String PREFIX = "AuthNumber:"; - private final String REISSUE_PREFIX = "Reissue:"; + private final RedisTemplate redisTemplate; public void saveAuthNumber(String key, String emailAuthNumber) { redisTemplate.opsForValue() - .set(PREFIX + key, emailAuthNumber, EXPIRATION, TimeUnit.MILLISECONDS); + .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); } public void saveReissueAuthNumber(String key, String reissueAuthNumber) { 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..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,19 +3,17 @@ 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.stereotype.Component; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; -@Component -@RequiredArgsConstructor @Repository +@RequiredArgsConstructor 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/api/auth/service/strategy/EmailService.java b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java new file mode 100644 index 0000000..f7395fd --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/auth/service/strategy/EmailService.java @@ -0,0 +1,70 @@ +package org.example.plzdrawing.api.auth.service.strategy; + +import static org.example.plzdrawing.api.member.exception.MemberErrorCode.MEMBER_NOT_FOUND; +import static org.example.plzdrawing.api.member.exception.MemberErrorCode.PASSWORD_INCORRECT; + +import lombok.RequiredArgsConstructor; +import org.example.plzdrawing.api.auth.dto.request.LoginRequest; +import org.example.plzdrawing.api.auth.dto.request.SignUpRequest; +import org.example.plzdrawing.api.auth.dto.response.LoginResponse; +import org.example.plzdrawing.api.auth.dto.response.SignUpResponse; +import org.example.plzdrawing.common.exception.RestApiException; +import org.example.plzdrawing.domain.member.Member; +import org.example.plzdrawing.domain.member.MemberRepository; +import org.example.plzdrawing.domain.member.Provider; +import org.example.plzdrawing.util.jwt.TokenService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class EmailService implements AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final TokenService tokenService; + + @Override + public LoginResponse login(LoginRequest request) { + Member member = memberRepository.findByEmailAndProvider(request.getEmail(), + request.getProvider()).orElseThrow(()->new RestApiException(MEMBER_NOT_FOUND.getErrorCode())); + + validatePassword(request, member); + + String accessToken = tokenService.createAccessToken(member.getId()); + String refreshToken = tokenService.createRefreshToken(member.getId()); + + return new LoginResponse(accessToken, refreshToken); + } + + @Override + public SignUpResponse signUp(SignUpRequest request) { + String encodedPassword = passwordEncoder.encode(request.getPassword()); + Member member = Member.builder() + .email(request.getEmail()) + .password(encodedPassword) + .provider(request.getProvider()) + .nickname(request.getNickName()) + .build(); + + Long savedId = memberRepository.save(member).getId(); + return new SignUpResponse(savedId); + } + + @Override + public Provider getProviderName() { + return Provider.EMAIL; + } + + private void validatePassword(LoginRequest request, Member member) { + if (!isPasswordMatching(request.getPassword(), member.getPassword())) { + throw new RestApiException(PASSWORD_INCORRECT.getErrorCode()); + } + } + + private boolean isPasswordMatching(String inputPassword, String savedPassword) { + return passwordEncoder.matches(inputPassword, savedPassword); + } +} 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/chat/controller/ChatController.java b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java new file mode 100644 index 0000000..197b463 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/controller/ChatController.java @@ -0,0 +1,40 @@ +package org.example.plzdrawing.api.chat.controller; + +import jakarta.validation.Valid; +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.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 ChatService chatService; + + @MessageMapping("/v1/chat") //app/v1/chat + public void sendMessage(@Payload @Valid ChatDto chatDto) { + chatService.saveMessage(chatDto, Timestamp.valueOf(LocalDateTime.now())); + } + + @GetMapping("/{chatRoomId}") + public ResponseEntity> getChats(@PathVariable("chatRoomId") String chatRoomId, + @RequestParam(name = "pageNum", defaultValue = "0") int pageNum) { + Page chatPage = chatService.getChats(chatRoomId, pageNum); + + //TODO 검증 1회만 + return ResponseEntity.ok(chatPage); + } +} \ No newline at end of file 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..f139544 --- /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.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..77c4c55 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/dto/request/ChatDto.java @@ -0,0 +1,29 @@ +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; + 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..016d9ad --- /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.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/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 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/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..74a408a --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chat/service/ChatServiceImpl.java @@ -0,0 +1,68 @@ +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.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; +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 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); + } + + @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); + } + + 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/controller/ChatRoomController.java b/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java new file mode 100644 index 0000000..ea68650 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/controller/ChatRoomController.java @@ -0,0 +1,40 @@ +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); + } +} 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..d8b94b9 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/converter/ChatRoomConverter.java @@ -0,0 +1,15 @@ +package org.example.plzdrawing.api.chatRoom.dto.converter; + +import org.example.plzdrawing.api.chatRoom.dto.response.ResponseChatRoom; +import org.example.plzdrawing.domain.chatroom.ChatRoom; + +public class ChatRoomConverter { + public static ResponseChatRoom fromEntity(ChatRoom chatRoom, String counterpartNickname, int unreadCount) { + return new ResponseChatRoom( + chatRoom.getChatRoomId(), + counterpartNickname, + chatRoom.getLastMessage(), + unreadCount + ); + } +} 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..237086c --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/dto/response/ResponseChatRoom.java @@ -0,0 +1,12 @@ +package org.example.plzdrawing.api.chatRoom.dto.response; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +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/exception/ChatRoomAlreadyExistsException.java b/src/main/java/org/example/plzdrawing/api/chatRoom/exception/ChatRoomAlreadyExistsException.java new file mode 100644 index 0000000..c75fd0d --- /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.chatroom.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..57e3e09 --- /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 { + CHATROOM_NOT_FOUND(new BaseErrorCode("CHATROOM_001", HttpStatus.NOT_FOUND,"존재하지 않는 채팅방입니다")) + ; + + private final ErrorCode errorCode; +} 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/ChatRoomService.java b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java new file mode 100644 index 0000000..955226d --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomService.java @@ -0,0 +1,18 @@ +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.Chat; +import org.example.plzdrawing.domain.chatroom.ChatRoom; + +public interface ChatRoomService { + + List getChatRooms(Long memberId); + + 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 new file mode 100644 index 0000000..9c46f3c --- /dev/null +++ b/src/main/java/org/example/plzdrawing/api/chatRoom/service/ChatRoomServiceImpl.java @@ -0,0 +1,91 @@ +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.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; +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; + +@Service +@RequiredArgsConstructor +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) { + 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.getDisplayContent()); + } + + @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); + int unreadCount = unreadCountRepository.getUnreadCount(chatRoom.getChatRoomId(), memberId); + return ChatRoomConverter.fromEntity(chatRoom, counterpartNickname, unreadCount); + }) + .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())) { + 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/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/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/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/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/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/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(); + } +} 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 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/common/redis/RedisKeyPrefix.java b/src/main/java/org/example/plzdrawing/common/redis/RedisKeyPrefix.java new file mode 100644 index 0000000..d5aee9a --- /dev/null +++ b/src/main/java/org/example/plzdrawing/common/redis/RedisKeyPrefix.java @@ -0,0 +1,11 @@ +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:"; + public static final String REISSUE_PREFIX = "Reissue:"; + + private RedisKeyPrefix() {} +} diff --git a/src/main/java/org/example/plzdrawing/domain/chat/Chat.java b/src/main/java/org/example/plzdrawing/domain/chat/Chat.java new file mode 100644 index 0000000..3888c3e --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/Chat.java @@ -0,0 +1,56 @@ +package org.example.plzdrawing.domain.chat; + +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 boolean isRead; + + 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.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/ChatRepository.java b/src/main/java/org/example/plzdrawing/domain/chat/ChatRepository.java new file mode 100644 index 0000000..d9cc38f --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/ChatRepository.java @@ -0,0 +1,10 @@ +package org.example.plzdrawing.domain.chat; + +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/MessageType.java b/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java new file mode 100644 index 0000000..fa14425 --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chat/MessageType.java @@ -0,0 +1,19 @@ +package org.example.plzdrawing.domain.chat; + +public enum MessageType { + TEXT { + @Override + public String getDisplayContent(String message) { + return message; + } + }, + FILE { + @Override + public String getDisplayContent(String message) { + return "파일이 전송되었습니다."; + } + } + ; + + public abstract String getDisplayContent(String message); +} diff --git a/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoom.java b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoom.java new file mode 100644 index 0000000..cf074af --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoom.java @@ -0,0 +1,49 @@ +package org.example.plzdrawing.domain.chatroom; + +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/chatroom/ChatRoomRepository.java b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoomRepository.java new file mode 100644 index 0000000..59d583a --- /dev/null +++ b/src/main/java/org/example/plzdrawing/domain/chatroom/ChatRoomRepository.java @@ -0,0 +1,12 @@ +package org.example.plzdrawing.domain.chatroom; + +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); } 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 5586f2f..77dfb22 100644 --- a/src/main/java/org/example/plzdrawing/util/jwt/TokenService.java +++ b/src/main/java/org/example/plzdrawing/util/jwt/TokenService.java @@ -11,18 +11,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()); } 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..38faf0d --- /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("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 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/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..8e49159 --- /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.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; +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()); + } +} 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); + } +} 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: