From 2675fa34af5494f0f459ea47db7d90bf154f3e34 Mon Sep 17 00:00:00 2001 From: Yeon <84384499+lsy1307@users.noreply.github.com> Date: Wed, 27 Aug 2025 13:14:26 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=8B=A0=EA=B3=A0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅 신고 기능 추가 * feat: 테스트 코드 추가 --- .../report/domain/TargetType.java | 1 + .../report/service/ReportService.java | 9 ++- .../report/service/ReportServiceTest.java | 58 ++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/solidconnection/report/domain/TargetType.java b/src/main/java/com/example/solidconnection/report/domain/TargetType.java index c48f50ac0..a3a6d68af 100644 --- a/src/main/java/com/example/solidconnection/report/domain/TargetType.java +++ b/src/main/java/com/example/solidconnection/report/domain/TargetType.java @@ -3,5 +3,6 @@ public enum TargetType { POST, + CHAT ; } diff --git a/src/main/java/com/example/solidconnection/report/service/ReportService.java b/src/main/java/com/example/solidconnection/report/service/ReportService.java index 3546861ea..205ca293d 100644 --- a/src/main/java/com/example/solidconnection/report/service/ReportService.java +++ b/src/main/java/com/example/solidconnection/report/service/ReportService.java @@ -1,5 +1,6 @@ package com.example.solidconnection.report.service; +import com.example.solidconnection.chat.repository.ChatMessageRepository; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.community.post.repository.PostRepository; @@ -19,6 +20,7 @@ public class ReportService { private final ReportRepository reportRepository; private final SiteUserRepository siteUserRepository; private final PostRepository postRepository; + private final ChatMessageRepository chatMessageRepository; @Transactional public void createReport(long reporterId, ReportRequest request) { @@ -37,7 +39,12 @@ private void validateReporterExists(long reporterId) { } private void validateTargetExists(TargetType targetType, long targetId) { - if (targetType == TargetType.POST && !postRepository.existsById(targetId)) { + boolean exists = switch (targetType) { + case POST -> postRepository.existsById(targetId); + case CHAT -> chatMessageRepository.existsById(targetId); + }; + + if (!exists) { throw new CustomException(ErrorCode.REPORT_TARGET_NOT_FOUND); } } diff --git a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java index 23523ae34..cdc9b875f 100644 --- a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java +++ b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java @@ -3,6 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.fixture.ChatMessageFixture; +import com.example.solidconnection.chat.fixture.ChatRoomFixture; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.community.board.domain.Board; @@ -45,18 +49,27 @@ class ReportServiceTest { @Autowired private ReportFixture reportFixture; + @Autowired + private ChatRoomFixture chatRoomFixture; + + @Autowired + private ChatMessageFixture chatMessageFixture; + private SiteUser siteUser; private Post post; + private ChatMessage chatMessage; @BeforeEach void setUp() { siteUser = siteUserFixture.사용자(); Board board = boardFixture.자유게시판(); post = postFixture.게시글(board, siteUser); + ChatRoom chatRoom = chatRoomFixture.채팅방(false); + chatMessage = chatMessageFixture.메시지("채팅", siteUser.getId(), chatRoom); } @Nested - class 신고_생성 { + class 포스트_신고 { @Test void 정상적으로_신고한다() { @@ -96,4 +109,47 @@ class 신고_생성 { .hasMessageContaining(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER.getMessage()); } } + + @Nested + class 채팅_신고 { + + @Test + void 정상적으로_신고한다() { + // given + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.CHAT, chatMessage.getId()); + + // when + reportService.createReport(siteUser.getId(), request); + + // then + boolean isSaved = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + siteUser.getId(), TargetType.CHAT, chatMessage.getId()); + assertThat(isSaved).isTrue(); + } + + @Test + void 신고_대상이_존재하지_않으면_예외가_발생한다() { + // given + long notExistingId = 999L; + ReportRequest request = new ReportRequest(ReportType.SPAM, TargetType.CHAT, notExistingId); + + // when & then + assertThatCode(() -> reportService.createReport(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + } + + @Test + void 이미_신고한_경우_예외가_발생한다() { + // given + reportFixture.신고(siteUser.getId(), TargetType.CHAT, chatMessage.getId()); + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.CHAT, chatMessage.getId()); + + // when & then + assertThatCode(() -> reportService.createReport(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER.getMessage()); + } + } } + From 4bd30ff9789ad9938499b906c6e3fba3698ec72b Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Thu, 28 Aug 2025 01:30:43 +0900 Subject: [PATCH 02/14] =?UTF-8?q?fix:=20=EB=AA=A8=EB=93=A0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EA=B0=80=20=EB=B3=BC=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/controller/ApplicationController.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java index 07571d060..c388f8b25 100644 --- a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -6,8 +6,6 @@ import com.example.solidconnection.application.service.ApplicationQueryService; import com.example.solidconnection.application.service.ApplicationSubmissionService; import com.example.solidconnection.common.resolver.AuthorizedUser; -import com.example.solidconnection.security.annotation.RequireRoleAccess; -import com.example.solidconnection.siteuser.domain.Role; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -39,7 +37,7 @@ public ResponseEntity apply( .body(applicationSubmissionResponse); } - @RequireRoleAccess(roles = {Role.ADMIN}) + // @RequireRoleAccess(roles = {Role.ADMIN}) // todo : 추후 어드민 페이지에서 권한 변경 기능 추가 필요 @GetMapping public ResponseEntity getApplicants( @AuthorizedUser long siteUserId, From 1e5f2a453f278684aea8effa1cbc3b04c9d86a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EA=B7=9C=ED=98=81?= <126947828+Gyuhyeok99@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:36:59 +0900 Subject: [PATCH 03/14] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20n=20+=201=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#477)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: n + 1 문제 개선 - 1 + 3N + 1 -> 1 + 3 + 1로 쿼리 개선 * refactor: 함수 가독성 개선 --- .../chat/dto/ChatRoomData.java | 24 ++++++++ .../chat/dto/UnreadCountDto.java | 8 +++ .../repository/ChatMessageRepository.java | 33 ++++++++++- .../chat/repository/ChatRoomRepository.java | 27 +++------ .../chat/service/ChatService.java | 56 +++++++++++++------ .../repository/SiteUserRepository.java | 2 + 6 files changed, 113 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/chat/dto/ChatRoomData.java create mode 100644 src/main/java/com/example/solidconnection/chat/dto/UnreadCountDto.java diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatRoomData.java b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomData.java new file mode 100644 index 000000000..491df930a --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomData.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.chat.dto; + +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.siteuser.domain.SiteUser; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record ChatRoomData( + Map latestMessages, + Map unreadCounts, + Map partnerUsers +) { + + public static ChatRoomData from(List latestMessages, + List unreadCounts, + List partnerUsers) { + return new ChatRoomData( + latestMessages.stream().collect(Collectors.toMap(msg -> msg.getChatRoom().getId(), msg -> msg)), + unreadCounts.stream().collect(Collectors.toMap(UnreadCountDto::chatRoomId, UnreadCountDto::count)), + partnerUsers.stream().collect(Collectors.toMap(SiteUser::getId, user -> user)) + ); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/UnreadCountDto.java b/src/main/java/com/example/solidconnection/chat/dto/UnreadCountDto.java new file mode 100644 index 000000000..a6fd9fc77 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/UnreadCountDto.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.chat.dto; + +public record UnreadCountDto( + long chatRoomId, + long count +) { + +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java index ad0f15630..e27e3e86d 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -1,7 +1,8 @@ package com.example.solidconnection.chat.repository; import com.example.solidconnection.chat.domain.ChatMessage; -import java.util.Optional; +import com.example.solidconnection.chat.dto.UnreadCountDto; +import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -18,5 +19,33 @@ public interface ChatMessageRepository extends JpaRepository """) Slice findByRoomIdWithPaging(@Param("roomId") long roomId, Pageable pageable); - Optional findFirstByChatRoomIdOrderByCreatedAtDesc(long chatRoomId); + @Query(""" + SELECT cm FROM ChatMessage cm + WHERE cm.id IN ( + SELECT MAX(cm2.id) + FROM ChatMessage cm2 + WHERE cm2.chatRoom.id IN :chatRoomIds + GROUP BY cm2.chatRoom.id + ) + """) + List findLatestMessagesByChatRoomIds(@Param("chatRoomIds") List chatRoomIds); + + @Query(""" + SELECT new com.example.solidconnection.chat.dto.UnreadCountDto( + cm.chatRoom.id, + COUNT(cm) + ) + FROM ChatMessage cm + LEFT JOIN ChatReadStatus crs ON crs.chatRoomId = cm.chatRoom.id + AND crs.chatParticipantId = ( + SELECT cp.id FROM ChatParticipant cp + WHERE cp.chatRoom.id = cm.chatRoom.id + AND cp.siteUserId = :userId + ) + WHERE cm.chatRoom.id IN :chatRoomIds + AND cm.senderId != :userId + AND (crs.updatedAt IS NULL OR cm.createdAt > crs.updatedAt) + GROUP BY cm.chatRoom.id + """) + List countUnreadMessagesBatch(@Param("chatRoomIds") List chatRoomIds, @Param("userId") long userId); } diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java index 8c0b81a4b..28847f906 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java @@ -9,30 +9,21 @@ public interface ChatRoomRepository extends JpaRepository { @Query(""" - SELECT cr FROM ChatRoom cr - JOIN cr.chatParticipants cp - WHERE cp.siteUserId = :userId AND cr.isGroup = false + SELECT DISTINCT cr FROM ChatRoom cr + JOIN FETCH cr.chatParticipants + WHERE cr.id IN ( + SELECT DISTINCT cp2.chatRoom.id + FROM ChatParticipant cp2 + WHERE cp2.siteUserId = :userId + ) + AND cr.isGroup = false ORDER BY ( SELECT MAX(cm.createdAt) FROM ChatMessage cm WHERE cm.chatRoom = cr ) DESC NULLS LAST """) - List findOneOnOneChatRoomsByUserId(@Param("userId") long userId); - - @Query(""" - SELECT COUNT(cm) FROM ChatMessage cm - LEFT JOIN ChatReadStatus crs ON crs.chatRoomId = cm.chatRoom.id - AND crs.chatParticipantId = ( - SELECT cp.id FROM ChatParticipant cp - WHERE cp.chatRoom.id = :chatRoomId - AND cp.siteUserId = :userId - ) - WHERE cm.chatRoom.id = :chatRoomId - AND cm.senderId != :userId - AND (crs.updatedAt IS NULL OR cm.createdAt > crs.updatedAt) - """) - long countUnreadMessages(@Param("chatRoomId") long chatRoomId, @Param("userId") long userId); + List findOneOnOneChatRoomsByUserIdWithParticipants(@Param("userId") long userId); ChatRoom findByMentoringId(long mentoringId); diff --git a/src/main/java/com/example/solidconnection/chat/service/ChatService.java b/src/main/java/com/example/solidconnection/chat/service/ChatService.java index ae9be659b..1bd372d81 100644 --- a/src/main/java/com/example/solidconnection/chat/service/ChatService.java +++ b/src/main/java/com/example/solidconnection/chat/service/ChatService.java @@ -21,11 +21,11 @@ import com.example.solidconnection.chat.repository.ChatRoomRepository; import com.example.solidconnection.common.dto.SliceResponse; import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.chat.dto.ChatRoomData; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import java.time.ZonedDateTime; +import java.util.Collections; import java.util.List; -import java.util.Optional; import org.springframework.context.annotation.Lazy; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -60,28 +60,50 @@ public ChatService(ChatRoomRepository chatRoomRepository, @Transactional(readOnly = true) public ChatRoomListResponse getChatRooms(long siteUserId) { - // todo : n + 1 문제 해결 필요! - List chatRooms = chatRoomRepository.findOneOnOneChatRoomsByUserId(siteUserId); - List chatRoomInfos = chatRooms.stream() - .map(chatRoom -> toChatRoomResponse(chatRoom, siteUserId)) + List chatRooms = chatRoomRepository.findOneOnOneChatRoomsByUserIdWithParticipants(siteUserId); + + if (chatRooms.isEmpty()) { + return ChatRoomListResponse.of(Collections.emptyList()); + } + + ChatRoomData chatRoomData = getChatRoomData(chatRooms, siteUserId); + + List responses = chatRooms.stream() + .map(chatRoom -> createChatRoomResponse(chatRoom, siteUserId, chatRoomData)) .toList(); - return ChatRoomListResponse.of(chatRoomInfos); + + return ChatRoomListResponse.of(responses); } - private ChatRoomResponse toChatRoomResponse(ChatRoom chatRoom, long siteUserId) { - Optional latestMessage = chatMessageRepository.findFirstByChatRoomIdOrderByCreatedAtDesc(chatRoom.getId()); - String lastChatMessage = latestMessage.map(ChatMessage::getContent).orElse(""); - ZonedDateTime lastReceivedTime = latestMessage.map(ChatMessage::getCreatedAt).orElse(null); + private ChatRoomData getChatRoomData(List chatRooms, long siteUserId) { + List chatRoomIds = chatRooms.stream().map(ChatRoom::getId).toList(); + List partnerUserIds = chatRooms.stream() + .map(chatRoom -> findPartner(chatRoom, siteUserId).getSiteUserId()) + .toList(); - ChatParticipant partnerParticipant = findPartner(chatRoom, siteUserId); + return ChatRoomData.from( + chatMessageRepository.findLatestMessagesByChatRoomIds(chatRoomIds), + chatMessageRepository.countUnreadMessagesBatch(chatRoomIds, siteUserId), + siteUserRepository.findAllByIdIn(partnerUserIds) + ); + } - SiteUser siteUser = siteUserRepository.findById(partnerParticipant.getSiteUserId()) - .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - ChatParticipantResponse partner = ChatParticipantResponse.of(siteUser.getId(), siteUser.getNickname(), siteUser.getProfileImageUrl()); + private ChatRoomResponse createChatRoomResponse(ChatRoom chatRoom, long siteUserId, ChatRoomData chatRoomData) { + ChatMessage latestMessage = chatRoomData.latestMessages().get(chatRoom.getId()); + ChatParticipant partner = findPartner(chatRoom, siteUserId); + SiteUser partnerUser = chatRoomData.partnerUsers().get(partner.getSiteUserId()); - long unReadCount = chatRoomRepository.countUnreadMessages(chatRoom.getId(), siteUserId); + if (partnerUser == null) { + throw new CustomException(USER_NOT_FOUND); + } - return ChatRoomResponse.of(chatRoom.getId(), lastChatMessage, lastReceivedTime, partner, unReadCount); + return ChatRoomResponse.of( + chatRoom.getId(), + latestMessage != null ? latestMessage.getContent() : "", + latestMessage != null ? latestMessage.getCreatedAt() : null, + ChatParticipantResponse.of(partnerUser.getId(), partnerUser.getNickname(), partnerUser.getProfileImageUrl()), + chatRoomData.unreadCounts().getOrDefault(chatRoom.getId(), 0L) + ); } @Transactional(readOnly = true) diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java index 864e72ed3..73422ba9f 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -19,4 +19,6 @@ public interface SiteUserRepository extends JpaRepository { @Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate") List findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate); + + List findAllByIdIn(List ids); } From 91feb3dda5df5068110340ffb6a87d70ec88883a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EA=B7=9C=ED=98=81?= <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sat, 30 Aug 2025 17:35:18 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B9=AD=EB=90=9C=20?= =?UTF-8?q?=EB=A9=98=ED=8B=B0/=EB=A9=98=ED=86=A0=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#482)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 멘티의 매칭된 멘토 조회 관련 dto 추가 * feat: 멘티의 매칭된 멘토 조회 관련 repository 추가 * feat: 멘티의 매칭된 멘토 조회 관련 로직 추가 * test: 멘티의 매칭된 멘토 조회 관련 테스트 추구ㅏ * refactor: 멘토링을 먼저 조회하도록 수정 --- .../MentoringForMenteeController.java | 16 ++++ .../mentor/dto/MatchedMentorResponse.java | 46 ++++++++++ .../repository/MentoringRepository.java | 11 +++ .../mentor/service/MentoringQueryService.java | 56 ++++++++++++ .../service/MentoringQueryServiceTest.java | 91 ++++++++++++++++++- 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/mentor/dto/MatchedMentorResponse.java diff --git a/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMenteeController.java b/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMenteeController.java index 28683cd37..06d77b073 100644 --- a/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMenteeController.java +++ b/src/main/java/com/example/solidconnection/mentor/controller/MentoringForMenteeController.java @@ -5,6 +5,7 @@ import com.example.solidconnection.common.resolver.AuthorizedUser; import com.example.solidconnection.mentor.dto.CheckMentoringRequest; import com.example.solidconnection.mentor.dto.CheckedMentoringsResponse; +import com.example.solidconnection.mentor.dto.MatchedMentorResponse; import com.example.solidconnection.mentor.dto.MentoringApplyRequest; import com.example.solidconnection.mentor.dto.MentoringApplyResponse; import com.example.solidconnection.mentor.dto.MentoringForMenteeResponse; @@ -38,6 +39,21 @@ public class MentoringForMenteeController { private final MentoringQueryService mentoringQueryService; private final MentoringCheckService mentoringCheckService; + @RequireRoleAccess(roles = Role.MENTEE) + @GetMapping("/matched-mentors") + public ResponseEntity> getMatchedMentors( + @AuthorizedUser long siteUserId, + @PageableDefault + @SortDefaults({ + @SortDefault(sort = "confirmedAt", direction = Sort.Direction.DESC), + @SortDefault(sort = "id", direction = Sort.Direction.DESC) + }) + Pageable pageable + ) { + SliceResponse response = mentoringQueryService.getMatchedMentors(siteUserId, pageable); + return ResponseEntity.ok(response); + } + @RequireRoleAccess(roles = Role.MENTEE) @PostMapping public ResponseEntity applyMentoring( diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MatchedMentorResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MatchedMentorResponse.java new file mode 100644 index 000000000..c959ea083 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MatchedMentorResponse.java @@ -0,0 +1,46 @@ +package com.example.solidconnection.mentor.dto; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.university.domain.University; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +public record MatchedMentorResponse( + long id, + + @JsonInclude(NON_NULL) + Long roomId, + + String nickname, + String profileImageUrl, + String country, + String universityName, + String term, + int menteeCount, + boolean hasBadge, + String introduction, + List channels, + boolean isApplied +) { + + public static MatchedMentorResponse of(Mentor mentor, SiteUser mentorUser, + University university, boolean isApplied, Long roomId) { + return new MatchedMentorResponse( + mentor.getId(), + roomId, + mentorUser.getNickname(), + mentorUser.getProfileImageUrl(), + university.getCountry().getKoreanName(), + university.getKoreanName(), + mentor.getTerm(), + mentor.getMenteeCount(), + mentor.isHasBadge(), + mentor.getIntroduction(), + mentor.getChannels().stream().map(ChannelResponse::from).toList(), + isApplied + ); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java index 16caf3318..16230b74b 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java @@ -28,4 +28,15 @@ public interface MentoringRepository extends JpaRepository { WHERE m.mentorId IN :mentorIds AND m.menteeId = :menteeId """) List findAllByMentorIdInAndMenteeId(@Param("mentorIds") List mentorIds, @Param("menteeId") long menteeId); + + @Query(""" + SELECT m FROM Mentoring m + WHERE m.menteeId = :menteeId + AND m.mentorId IN :mentorIds + AND m.verifyStatus = :verifyStatus + """) + List findApprovedMentoringsByMenteeIdAndMentorIds(@Param("menteeId") long menteeId, @Param("verifyStatus") VerifyStatus verifyStatus, @Param("mentorIds") List mentorIds); + + @Query("SELECT m FROM Mentoring m WHERE m.menteeId = :menteeId AND m.verifyStatus = :verifyStatus") + Slice findApprovedMentoringsByMenteeId(long menteeId, @Param("verifyStatus") VerifyStatus verifyStatus, Pageable pageable); } diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java b/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java index 1ef037706..57753f983 100644 --- a/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java +++ b/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java @@ -10,12 +10,15 @@ import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.mentor.domain.Mentor; import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.MatchedMentorResponse; import com.example.solidconnection.mentor.dto.MentoringForMenteeResponse; import com.example.solidconnection.mentor.dto.MentoringForMentorResponse; +import com.example.solidconnection.mentor.repository.MentorBatchQueryRepository; import com.example.solidconnection.mentor.repository.MentorRepository; import com.example.solidconnection.mentor.repository.MentoringRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.domain.University; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -35,6 +38,59 @@ public class MentoringQueryService { private final MentorRepository mentorRepository; private final SiteUserRepository siteUserRepository; private final ChatRoomRepository chatRoomRepository; + private final MentorBatchQueryRepository mentorBatchQueryRepository; + + @Transactional(readOnly = true) + public SliceResponse getMatchedMentors(long siteUserId, Pageable pageable) { + Slice mentoringSlice = mentoringRepository.findApprovedMentoringsByMenteeId(siteUserId, VerifyStatus.APPROVED, pageable); + + List mentorIds = mentoringSlice.getContent().stream() + .map(Mentoring::getMentorId) + .distinct() + .toList(); + + List mentors = mentorRepository.findAllById(mentorIds); + + List content = buildMatchedMentorsWithBatchQuery(mentors, siteUserId); + + return SliceResponse.of(content, mentoringSlice); + } + + private List buildMatchedMentorsWithBatchQuery(List mentors, long currentUserId) { + Map mentorIdToSiteUser = mentorBatchQueryRepository.getMentorIdToSiteUserMap(mentors); + Map mentorIdToUniversity = mentorBatchQueryRepository.getMentorIdToUniversityMap(mentors); + Map mentorIdToIsApplied = mentorBatchQueryRepository.getMentorIdToIsApplied(mentors, currentUserId); + + Map mentorIdToRoomId = getMentorIdToRoomIdMap(mentors, currentUserId); + + List matchedMentors = new ArrayList<>(); + for (Mentor mentor : mentors) { + SiteUser mentorUser = mentorIdToSiteUser.get(mentor.getId()); + University university = mentorIdToUniversity.get(mentor.getId()); + boolean isApplied = mentorIdToIsApplied.get(mentor.getId()); + Long roomId = mentorIdToRoomId.get(mentor.getId()); + MatchedMentorResponse response = MatchedMentorResponse.of(mentor, mentorUser, university, isApplied, roomId); + matchedMentors.add(response); + } + return matchedMentors; + } + + private Map getMentorIdToRoomIdMap(List mentors, long menteeUserId) { + List mentorIds = mentors.stream().map(Mentor::getId).toList(); + List approvedMentorings = mentoringRepository.findApprovedMentoringsByMenteeIdAndMentorIds(menteeUserId, VerifyStatus.APPROVED, mentorIds); + + List mentoringIds = approvedMentorings.stream().map(Mentoring::getId).toList(); + List chatRooms = chatRoomRepository.findAllByMentoringIdIn(mentoringIds); + + Map mentoringIdToRoomId = chatRooms.stream() + .collect(Collectors.toMap(ChatRoom::getMentoringId, ChatRoom::getId)); + + return approvedMentorings.stream() + .collect(Collectors.toMap( + Mentoring::getMentorId, + mentoring -> mentoringIdToRoomId.get(mentoring.getId()) + )); + } @Transactional(readOnly = true) public SliceResponse getMentoringsForMentee( diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java index 867af6e56..cb680b986 100644 --- a/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.AssertionsForClassTypes.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; import com.example.solidconnection.chat.domain.ChatRoom; import com.example.solidconnection.chat.fixture.ChatRoomFixture; @@ -10,16 +11,25 @@ import com.example.solidconnection.common.dto.SliceResponse; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.mentor.domain.Channel; import com.example.solidconnection.mentor.domain.Mentor; import com.example.solidconnection.mentor.domain.Mentoring; +import com.example.solidconnection.mentor.dto.ChannelResponse; +import com.example.solidconnection.mentor.dto.MatchedMentorResponse; import com.example.solidconnection.mentor.dto.MentoringForMenteeResponse; import com.example.solidconnection.mentor.dto.MentoringForMentorResponse; +import com.example.solidconnection.mentor.fixture.ChannelFixture; import com.example.solidconnection.mentor.fixture.MentorFixture; import com.example.solidconnection.mentor.fixture.MentoringFixture; import com.example.solidconnection.mentor.repository.MentoringRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.fixture.UniversityFixture; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -44,6 +54,12 @@ class MentoringQueryServiceTest { @Autowired private MentoringFixture mentoringFixture; + @Autowired + private UniversityFixture universityFixture; + + @Autowired + private ChannelFixture channelFixture; + @Autowired private MentoringRepository mentoringRepository; @@ -53,6 +69,7 @@ class MentoringQueryServiceTest { private SiteUser mentorUser1, mentorUser2; private SiteUser menteeUser1, menteeUser2, menteeUser3; private Mentor mentor1, mentor2, mentor3; + private University university; private Pageable pageable; @BeforeEach @@ -63,9 +80,10 @@ void setUp() { menteeUser1 = siteUserFixture.사용자(1, "mentee1"); menteeUser2 = siteUserFixture.사용자(2, "mentee2"); menteeUser3 = siteUserFixture.사용자(3, "mentee3"); - mentor1 = mentorFixture.멘토(mentorUser1.getId(), 1L); - mentor2 = mentorFixture.멘토(mentorUser2.getId(), 1L); - mentor3 = mentorFixture.멘토(mentorUser3.getId(), 1L); + university = universityFixture.괌_대학(); + mentor1 = mentorFixture.멘토(mentorUser1.getId(), university.getId()); + mentor2 = mentorFixture.멘토(mentorUser2.getId(), university.getId()); + mentor3 = mentorFixture.멘토(mentorUser3.getId(), university.getId()); pageable = PageRequest.of(0, 3); } @@ -239,4 +257,71 @@ class 멘티의_멘토링_목록_조회_테스트 { assertThat(response.content()).isEmpty(); } } + + @Nested + class 멘티의_멘토_목록_조회_테스트 { + + private static final int NO_NEXT_PAGE_NUMBER = -1; + + private Mentoring mentoring1, mentoring2; + private ChatRoom chatRoom1, chatRoom2; + + @BeforeEach + void setUp() { + mentoring1 = mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser1.getId()); + mentoring2 = mentoringFixture.승인된_멘토링(mentor2.getId(), menteeUser1.getId()); + + chatRoom1 = chatRoomFixture.멘토링_채팅방(mentoring1.getId()); + chatRoom2 = chatRoomFixture.멘토링_채팅방(mentoring2.getId()); + } + + @Test + void 매칭된_멘토의_목록_정보를_조회한다() { + // given + Channel channel1 = channelFixture.채널(1, mentor1); + Channel channel2 = channelFixture.채널(2, mentor2); + + // when + SliceResponse response = mentoringQueryService.getMatchedMentors(menteeUser1.getId(), PageRequest.of(0, 10)); + + // then + Map matchMentorMap = response.content().stream() + .collect(Collectors.toMap(MatchedMentorResponse::id, Function.identity())); + MatchedMentorResponse mentor1Response = matchMentorMap.get(mentor1.getId()); + MatchedMentorResponse mentor2Response = matchMentorMap.get(mentor2.getId()); + assertAll( + () -> assertThat(mentor1Response.roomId()).isEqualTo(chatRoom1.getId()), + () -> assertThat(mentor1Response.nickname()).isEqualTo(mentorUser1.getNickname()), + () -> assertThat(mentor1Response.universityName()).isEqualTo(university.getKoreanName()), + () -> assertThat(mentor1Response.country()).isEqualTo(university.getCountry().getKoreanName()), + () -> assertThat(mentor1Response.channels()).extracting(ChannelResponse::url) + .containsOnly(channel1.getUrl()), + + () -> assertThat(mentor2Response.roomId()).isEqualTo(chatRoom2.getId()), + () -> assertThat(mentor2Response.nickname()).isEqualTo(mentorUser2.getNickname()), + () -> assertThat(mentor2Response.universityName()).isEqualTo(university.getKoreanName()), + () -> assertThat(mentor2Response.country()).isEqualTo(university.getCountry().getKoreanName()), + () -> assertThat(mentor2Response.channels()).extracting(ChannelResponse::url) + .containsOnly(channel2.getUrl()) + ); + } + + @Test + void 다음_페이지_번호를_응답한다() { + // given + SliceResponse response = mentoringQueryService.getMatchedMentors(menteeUser1.getId(), PageRequest.of(0, 1)); + + // then + assertThat(response.nextPageNumber()).isEqualTo(2); + } + + @Test + void 다음_페이지가_없으면_페이지_없음을_의미하는_값을_응답한다() { + // given + SliceResponse response = mentoringQueryService.getMatchedMentors(menteeUser1.getId(), PageRequest.of(0, 10)); + + // then + assertThat(response.nextPageNumber()).isEqualTo(NO_NEXT_PAGE_NUMBER); + } + } } From 2eab140dc43e20a29a85030b59427962578b8f11 Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Sat, 30 Aug 2025 22:42:57 +0900 Subject: [PATCH 05/14] =?UTF-8?q?refactor:=20Token=20VO=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9,=20=EC=A0=9C=EA=B3=B5=EA=B3=BC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EA=B4=80=EC=8B=AC=EC=82=AC=20=EB=B6=84=EB=A6=AC,=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Token VO 패키지 이동 * refactor: Token VO의 구성을 단순화 - '토큰을 객체로 만들 때, 실제로 그 토큰이 가진 성질을 100% 반영해야 하는가?'에 대해서 고민이 되었다. 그런데 실제로 코드에서 토큰이 사용되는 것은 "문자열로 구성된 발급"이 대부분이므로, 토큰이 실제로 가지는 claim들을 100% 구현하는 것은 오히려 복잡도를 증가시킨다는 생각이 들었다. * test: 변경된 토큰 객체를 테스트 코드에 반영 * refactor: 토큰 '저장소'와 '제공자' 클래스 분리 * refactor: TokenProvider가 jwt 의존하지 않도록 메서드 시그니처 변경 * test: TokenStorage와 TokenProvider 변경사항 테스트코드에 반영 - auth 하위의 테스트 코드에 'RedisTemplate' autowired 존재하지 않게 - io.jsonwebtoken.Claims 에 대한 참조는 반드시 필요한 곳에만 존재하게 * refactor: SignUpToken VO 생성, Token VO가 공통 인터페이스 구현하도록 * refactor: signup 관련 클래스 하나의 패키지로 위치 * refactor: signin 관련 클래스 하나의 패키지로 위치 * refactor: 토큰 관련 변수를 환경 변수로 관리 * refactor: 토큰 생성 시, TokenType을 사용하지 않도록 - 토큰과 관련된 설정 값은 TokenType이 아니라 환경변수를 사용하도록 점진적으로 변경한다. * refactor: parseSubject함수의 반환 타입이 Subject가 되도록 * refactor: TokenStorage의 함수들이 값 객체를 인자로 갖도록 - 값 객체와 제네릭을 최대한 활용하여 코드에 의미를 전달하고 중복 코드를 줄인다. * test: 메서드 시그니처 변경 테스트 코드에 반영 * test: 변수 분리로 중복 코드 제거 * refactor: TokenValue를 사용하지 않고, TokenProperties를 사용하도록 * refactor: 로그아웃 시, 액세스 토큰을 새로 만드는 부분 제거 * chore: 주석 보강 * refactor: 회원가입 토큰 검증 시, 서버에 저장된 값과 동일한지 검증 추가 - code rabbit 리뷰 반영: https://github.com/solid-connection/solid-connect-server/pull/479#discussion_r2296724578 * refactor: 회원가입 토큰 검증 시, subject 존재 검증 추가 - code rabbit 리뷰 반영: https://github.com/solid-connection/solid-connect-server/pull/479#discussion_r2296724579 * refactor: subject 추출 시, subject가 없으면 예외 발생하도록 - code rabbit 리뷰 반영: https://github.com/solid-connection/solid-connect-server/pull/479#discussion_r2296724585 * refactor: blacklist token 저장 시 TTL 설정되도록 - code rabbit 리뷰 반영: https://github.com/solid-connection/solid-connect-server/pull/479#discussion_r2296724588 * refactor: duration 사용으로 시간 단위 통일 - application.yml을 보면 알다시피 더 명시적이게 바뀐다는 장점도 있다. - code rabbit 리뷰 반영: https://github.com/solid-connection/solid-connect-server/pull/479#discussion_r2296724581 --- .../auth/client/AppleOAuthClient.java | 5 +- .../auth/client/KakaoOAuthClient.java | 9 +- .../auth/controller/AuthController.java | 6 +- .../controller/RefreshTokenCookieManager.java | 13 +- .../auth/domain/AccessToken.java | 7 + .../auth/domain/RefreshToken.java | 7 + .../auth/domain/SignUpToken.java | 7 + .../auth/{service => domain}/Subject.java | 2 +- .../solidconnection/auth/domain/Token.java | 6 + .../auth/domain/TokenType.java | 25 --- .../auth/dto/ReissueResponse.java | 2 +- .../auth/dto/SignInResponse.java | 4 +- .../auth/service/AccessToken.java | 14 -- .../auth/service/AuthService.java | 7 +- .../auth/service/AuthTokenProvider.java | 43 ++--- .../auth/service/RefreshToken.java | 11 -- .../auth/service/SignUpTokenProvider.java | 84 ---------- .../auth/service/TokenProvider.java | 14 +- .../auth/service/TokenStorage.java | 14 ++ .../auth/service/oauth/OAuthService.java | 9 +- .../{ => signin}/EmailSignInService.java | 2 +- .../service/{ => signin}/SignInService.java | 5 +- .../EmailSignUpTokenProvider.java | 4 +- .../PasswordTemporaryStorage.java | 9 +- .../service/{ => signup}/SignUpService.java | 3 +- .../service/signup/SignUpTokenProvider.java | 72 +++++++++ .../auth/token/JwtTokenProvider.java | 40 ++--- .../auth/token/RedisTokenStorage.java | 47 ++++++ .../auth/token/TokenBlackListService.java | 23 ++- .../auth/token/config/TokenConfig.java | 10 ++ .../auth/token/config/TokenProperties.java | 38 +++++ .../TokenAuthenticationProvider.java | 2 +- .../RefreshTokenCookieManagerTest.java | 7 +- .../auth/service/AuthServiceTest.java | 18 ++- .../auth/service/AuthTokenProviderTest.java | 40 +++-- .../auth/service/JwtTokenProviderTest.java | 149 +++++++++--------- .../service/TokenBlackListServiceTest.java | 17 +- .../{ => signin}/EmailSignInServiceTest.java | 2 +- .../{ => signin}/SignInServiceTest.java | 23 +-- .../PasswordTemporaryStorageTest.java | 2 +- .../{ => signup}/SignUpTokenProviderTest.java | 80 ++++------ .../auth/token/RedisTokenStorageTest.java | 90 +++++++++++ .../filter/SignOutCheckFilterTest.java | 7 +- .../WebSocketStompIntegrationTest.java | 2 +- src/test/resources/application.yml | 11 ++ 45 files changed, 592 insertions(+), 400 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/auth/domain/AccessToken.java create mode 100644 src/main/java/com/example/solidconnection/auth/domain/RefreshToken.java create mode 100644 src/main/java/com/example/solidconnection/auth/domain/SignUpToken.java rename src/main/java/com/example/solidconnection/auth/{service => domain}/Subject.java (50%) create mode 100644 src/main/java/com/example/solidconnection/auth/domain/Token.java delete mode 100644 src/main/java/com/example/solidconnection/auth/domain/TokenType.java delete mode 100644 src/main/java/com/example/solidconnection/auth/service/AccessToken.java delete mode 100644 src/main/java/com/example/solidconnection/auth/service/RefreshToken.java delete mode 100644 src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java create mode 100644 src/main/java/com/example/solidconnection/auth/service/TokenStorage.java rename src/main/java/com/example/solidconnection/auth/service/{ => signin}/EmailSignInService.java (96%) rename src/main/java/com/example/solidconnection/auth/service/{ => signin}/SignInService.java (78%) rename src/main/java/com/example/solidconnection/auth/service/{ => signup}/EmailSignUpTokenProvider.java (93%) rename src/main/java/com/example/solidconnection/auth/service/{ => signup}/PasswordTemporaryStorage.java (84%) rename src/main/java/com/example/solidconnection/auth/service/{ => signup}/SignUpService.java (97%) create mode 100644 src/main/java/com/example/solidconnection/auth/service/signup/SignUpTokenProvider.java create mode 100644 src/main/java/com/example/solidconnection/auth/token/RedisTokenStorage.java create mode 100644 src/main/java/com/example/solidconnection/auth/token/config/TokenConfig.java create mode 100644 src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java rename src/test/java/com/example/solidconnection/auth/service/{ => signin}/EmailSignInServiceTest.java (97%) rename src/test/java/com/example/solidconnection/auth/service/{ => signin}/SignInServiceTest.java (65%) rename src/test/java/com/example/solidconnection/auth/service/{ => signup}/PasswordTemporaryStorageTest.java (96%) rename src/test/java/com/example/solidconnection/auth/service/{ => signup}/SignUpTokenProviderTest.java (61%) create mode 100644 src/test/java/com/example/solidconnection/auth/token/RedisTokenStorageTest.java diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java index 48009cc82..9b17214ee 100644 --- a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -25,8 +25,9 @@ import org.springframework.web.client.RestTemplate; /* - * 애플 인증을 위한 OAuth2 클라이언트 - * https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens + * - 애플 인증을 위한 OAuth2 클라이언트 + * - https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens + * - OAuthClient 인터페이스를 사용하는 전략 패턴으로 구현됨 * */ @Component @RequiredArgsConstructor diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index a25743f7d..b65e70417 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -23,10 +23,11 @@ import org.springframework.web.util.UriComponentsBuilder; /* - * 카카오 인증을 위한 OAuth2 클라이언트 - * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code - * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token - * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + * - 카카오 인증을 위한 OAuth2 클라이언트 + * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code + * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token + * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + * - OAuthClient 인터페이스를 사용하는 전략 패턴으로 구현됨 * */ @Component @RequiredArgsConstructor diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index f5a30bb2f..9fbd2f225 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -10,10 +10,10 @@ import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; import com.example.solidconnection.auth.service.AuthService; -import com.example.solidconnection.auth.service.EmailSignInService; -import com.example.solidconnection.auth.service.EmailSignUpTokenProvider; -import com.example.solidconnection.auth.service.SignUpService; import com.example.solidconnection.auth.service.oauth.OAuthService; +import com.example.solidconnection.auth.service.signin.EmailSignInService; +import com.example.solidconnection.auth.service.signup.EmailSignUpTokenProvider; +import com.example.solidconnection.auth.service.signup.SignUpService; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.common.resolver.AuthorizedUser; diff --git a/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java b/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java index 7c6f4ec04..6b22aa326 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java +++ b/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java @@ -3,11 +3,12 @@ import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_NOT_EXISTS; import com.example.solidconnection.auth.controller.config.RefreshTokenCookieProperties; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.token.config.TokenProperties; import com.example.solidconnection.common.exception.CustomException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.springframework.boot.web.server.Cookie.SameSite; @@ -23,15 +24,17 @@ public class RefreshTokenCookieManager { private static final String PATH = "/"; private final RefreshTokenCookieProperties properties; + private final TokenProperties tokenProperties; public void setCookie(HttpServletResponse response, String refreshToken) { - long maxAge = convertExpireTimeToCookieMaxAge(TokenType.REFRESH.getExpireTime()); - setRefreshTokenCookie(response, refreshToken, maxAge); + Duration tokenExpireTime = tokenProperties.refresh().expireTime(); + long cookieMaxAge = convertExpireTimeToCookieMaxAge(tokenExpireTime); + setRefreshTokenCookie(response, refreshToken, cookieMaxAge); } - private long convertExpireTimeToCookieMaxAge(long milliSeconds) { + private long convertExpireTimeToCookieMaxAge(Duration tokenExpireTime) { // jwt의 expireTime 단위인 millisecond를 cookie의 maxAge 단위인 second로 변환 - return milliSeconds / 1000; + return tokenExpireTime.toSeconds(); } public void deleteCookie(HttpServletResponse response) { diff --git a/src/main/java/com/example/solidconnection/auth/domain/AccessToken.java b/src/main/java/com/example/solidconnection/auth/domain/AccessToken.java new file mode 100644 index 000000000..07df18ff6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/AccessToken.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.domain; + +public record AccessToken( + String token +) implements Token { + +} diff --git a/src/main/java/com/example/solidconnection/auth/domain/RefreshToken.java b/src/main/java/com/example/solidconnection/auth/domain/RefreshToken.java new file mode 100644 index 000000000..aa0680ae7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/RefreshToken.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.domain; + +public record RefreshToken( + String token +) implements Token { + +} diff --git a/src/main/java/com/example/solidconnection/auth/domain/SignUpToken.java b/src/main/java/com/example/solidconnection/auth/domain/SignUpToken.java new file mode 100644 index 000000000..aed55920c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/SignUpToken.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.domain; + +public record SignUpToken( + String token +) implements Token { + +} diff --git a/src/main/java/com/example/solidconnection/auth/service/Subject.java b/src/main/java/com/example/solidconnection/auth/domain/Subject.java similarity index 50% rename from src/main/java/com/example/solidconnection/auth/service/Subject.java rename to src/main/java/com/example/solidconnection/auth/domain/Subject.java index 15e5c6c75..3a0e29448 100644 --- a/src/main/java/com/example/solidconnection/auth/service/Subject.java +++ b/src/main/java/com/example/solidconnection/auth/domain/Subject.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.domain; public record Subject( String value diff --git a/src/main/java/com/example/solidconnection/auth/domain/Token.java b/src/main/java/com/example/solidconnection/auth/domain/Token.java new file mode 100644 index 000000000..3613bffb0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/Token.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.auth.domain; + +public interface Token { + + String token(); +} diff --git a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java deleted file mode 100644 index 560b0e139..000000000 --- a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.solidconnection.auth.domain; - -import lombok.Getter; - -@Getter -public enum TokenType { - - ACCESS("ACCESS:", 1000L * 60 * 60), // 1hour - REFRESH("REFRESH:", 1000L * 60 * 60 * 24 * 90), // 90days - BLACKLIST("BLACKLIST:", ACCESS.expireTime), - SIGN_UP("SIGN_UP:", 1000L * 60 * 10), // 10min - ; - - private final String prefix; - private final long expireTime; - - TokenType(String prefix, long expireTime) { - this.prefix = prefix; - this.expireTime = expireTime; - } - - public String addPrefix(String string) { - return prefix + string; - } -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java b/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java index 972470cca..434198c32 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/ReissueResponse.java @@ -1,6 +1,6 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.auth.service.AccessToken; +import com.example.solidconnection.auth.domain.AccessToken; public record ReissueResponse( String accessToken diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java index b01fdd369..ac9d39290 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java @@ -1,7 +1,7 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.auth.service.AccessToken; -import com.example.solidconnection.auth.service.RefreshToken; +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.RefreshToken; public record SignInResponse( String accessToken, diff --git a/src/main/java/com/example/solidconnection/auth/service/AccessToken.java b/src/main/java/com/example/solidconnection/auth/service/AccessToken.java deleted file mode 100644 index 3456a2171..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/AccessToken.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.siteuser.domain.Role; - -public record AccessToken( - Subject subject, - Role role, - String token -) { - - public AccessToken(String subject, Role role, String token) { - this(new Subject(subject), role, token); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthService.java b/src/main/java/com/example/solidconnection/auth/service/AuthService.java index 01c162002..1c9478e80 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthService.java @@ -3,6 +3,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; +import com.example.solidconnection.auth.domain.AccessToken; import com.example.solidconnection.auth.dto.ReissueResponse; import com.example.solidconnection.auth.token.TokenBlackListService; import com.example.solidconnection.common.exception.CustomException; @@ -26,11 +27,9 @@ public class AuthService { * - 엑세스 토큰을 블랙리스트에 추가한다. * - 리프레시 토큰을 삭제한다. * */ - public void signOut(String token) { - SiteUser siteUser = authTokenProvider.parseSiteUser(token); - AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); - authTokenProvider.deleteRefreshTokenByAccessToken(accessToken); + public void signOut(String accessToken) { tokenBlackListService.addToBlacklist(accessToken); + authTokenProvider.deleteRefreshTokenByAccessToken(accessToken); } /* diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java index 8e55f77d4..ef188833b 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java @@ -2,7 +2,10 @@ import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.RefreshToken; +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.token.config.TokenProperties; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; @@ -10,7 +13,6 @@ import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @Component @@ -19,26 +21,30 @@ public class AuthTokenProvider { private static final String ROLE_CLAIM_KEY = "role"; - private final RedisTemplate redisTemplate; private final TokenProvider tokenProvider; + private final TokenStorage tokenStorage; private final SiteUserRepository siteUserRepository; + private final TokenProperties tokenProperties; public AccessToken generateAccessToken(SiteUser siteUser) { Subject subject = toSubject(siteUser); Role role = siteUser.getRole(); String token = tokenProvider.generateToken( - subject.value(), + subject, Map.of(ROLE_CLAIM_KEY, role.name()), - TokenType.ACCESS + tokenProperties.access().expireTime() ); - return new AccessToken(subject, role, token); + return new AccessToken(token); } public RefreshToken generateAndSaveRefreshToken(SiteUser siteUser) { Subject subject = toSubject(siteUser); - String token = tokenProvider.generateToken(subject.value(), TokenType.REFRESH); - tokenProvider.saveToken(token, TokenType.REFRESH); - return new RefreshToken(subject, token); + String token = tokenProvider.generateToken( + subject, + tokenProperties.refresh().expireTime() + ); + RefreshToken refreshToken = new RefreshToken(token); + return tokenStorage.saveToken(subject, refreshToken); } /* @@ -47,21 +53,20 @@ public RefreshToken generateAndSaveRefreshToken(SiteUser siteUser) { * - 조회된 리프레시 토큰과 요청된 토큰이 같은지 비교한다. * */ public boolean isValidRefreshToken(String requestedRefreshToken) { - String subject = tokenProvider.parseSubject(requestedRefreshToken); - String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); - String foundRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey); - return Objects.equals(requestedRefreshToken, foundRefreshToken); + Subject subject = tokenProvider.parseSubject(requestedRefreshToken); + return tokenStorage.findToken(subject, RefreshToken.class) + .map(foundRefreshToken -> Objects.equals(foundRefreshToken, requestedRefreshToken)) + .orElse(false); } - public void deleteRefreshTokenByAccessToken(AccessToken accessToken) { - String subject = accessToken.subject().value(); - String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); - redisTemplate.delete(refreshTokenKey); + public void deleteRefreshTokenByAccessToken(String accessToken) { + Subject subject = tokenProvider.parseSubject(accessToken); + tokenStorage.deleteToken(subject, RefreshToken.class); } public SiteUser parseSiteUser(String token) { - String subject = tokenProvider.parseSubject(token); - long siteUserId = Long.parseLong(subject); + Subject subject = tokenProvider.parseSubject(token); + long siteUserId = Long.parseLong(subject.value()); return siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); } diff --git a/src/main/java/com/example/solidconnection/auth/service/RefreshToken.java b/src/main/java/com/example/solidconnection/auth/service/RefreshToken.java deleted file mode 100644 index 2aac3ad8c..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/RefreshToken.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.solidconnection.auth.service; - -public record RefreshToken( - Subject subject, - String token -) { - - RefreshToken(String subject, String token) { - this(new Subject(subject), token); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java deleted file mode 100644 index 05480b10d..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.example.solidconnection.auth.service; - -import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; -import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; - -import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.auth.token.config.JwtProperties; -import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.siteuser.domain.AuthType; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class SignUpTokenProvider { - - private static final String AUTH_TYPE_CLAIM_KEY = "authType"; - - private final JwtProperties jwtProperties; - private final RedisTemplate redisTemplate; - private final TokenProvider tokenProvider; - - public String generateAndSaveSignUpToken(String email, AuthType authType) { - Map authTypeClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, authType)); - Claims claims = Jwts.claims(authTypeClaim).setSubject(email); - Date now = new Date(); - Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); - - String signUpToken = Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(expiredDate) - .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) - .compact(); - return tokenProvider.saveToken(signUpToken, TokenType.SIGN_UP); - } - - public void deleteByEmail(String email) { - String key = TokenType.SIGN_UP.addPrefix(email); - redisTemplate.delete(key); - } - - public void validateSignUpToken(String token) { - validateFormatAndExpiration(token); - String email = parseEmail(token); - validateIssuedByServer(email); - } - - private void validateFormatAndExpiration(String token) { // 파싱되는지, AuthType이 포함되어있는지 검증 - try { - Claims claims = tokenProvider.parseClaims(token); - Objects.requireNonNull(claims.getSubject()); - String serializedAuthType = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); - AuthType.valueOf(serializedAuthType); - } catch (Exception e) { - throw new CustomException(SIGN_UP_TOKEN_INVALID); - } - } - - private void validateIssuedByServer(String email) { - String key = TokenType.SIGN_UP.addPrefix(email); - if (redisTemplate.opsForValue().get(key) == null) { - throw new CustomException(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); - } - } - - public String parseEmail(String token) { - return tokenProvider.parseSubject(token); - } - - public AuthType parseAuthType(String token) { - Claims claims = tokenProvider.parseClaims(token); - String authTypeStr = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); - return AuthType.valueOf(authTypeStr); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java index 22120b084..32ce4eb8f 100644 --- a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java @@ -1,18 +1,16 @@ package com.example.solidconnection.auth.service; -import com.example.solidconnection.auth.domain.TokenType; -import io.jsonwebtoken.Claims; +import com.example.solidconnection.auth.domain.Subject; +import java.time.Duration; import java.util.Map; public interface TokenProvider { - String generateToken(String string, TokenType tokenType); + String generateToken(Subject subject, Duration expiration); - String generateToken(String string, Map claims, TokenType tokenType); + String generateToken(Subject subject, Map claims, Duration expiration); - String saveToken(String token, TokenType tokenType); + Subject parseSubject(String token); - String parseSubject(String token); - - Claims parseClaims(String token); + T parseClaims(String token, String claimName, Class claimType); } diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenStorage.java b/src/main/java/com/example/solidconnection/auth/service/TokenStorage.java new file mode 100644 index 000000000..19c3311f1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/TokenStorage.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.domain.Token; +import java.util.Optional; + +public interface TokenStorage { + + T saveToken(Subject subject, T token); + + Optional findToken(Subject subject, Class tokenClass); + + void deleteToken(Subject subject, Class tokenClass); +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java index 9343bfa21..08ab0c0b7 100644 --- a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -1,13 +1,14 @@ package com.example.solidconnection.auth.service.oauth; +import com.example.solidconnection.auth.domain.SignUpToken; import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; -import com.example.solidconnection.auth.service.SignInService; -import com.example.solidconnection.auth.service.SignUpTokenProvider; +import com.example.solidconnection.auth.service.signin.SignInService; +import com.example.solidconnection.auth.service.signup.SignUpTokenProvider; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -50,7 +51,7 @@ private OAuthSignInResponse getSignInResponse(SiteUser siteUser) { } private SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto, AuthType authType) { - String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), authType); - return SignUpPrepareResponse.of(userInfoDto, signUpToken); + SignUpToken signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), authType); + return SignUpPrepareResponse.of(userInfoDto, signUpToken.token()); } } diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java b/src/main/java/com/example/solidconnection/auth/service/signin/EmailSignInService.java similarity index 96% rename from src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java rename to src/main/java/com/example/solidconnection/auth/service/signin/EmailSignInService.java index 4dac56586..29f379a22 100644 --- a/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/signin/EmailSignInService.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signin; import static com.example.solidconnection.common.exception.ErrorCode.SIGN_IN_FAILED; diff --git a/src/main/java/com/example/solidconnection/auth/service/SignInService.java b/src/main/java/com/example/solidconnection/auth/service/signin/SignInService.java similarity index 78% rename from src/main/java/com/example/solidconnection/auth/service/SignInService.java rename to src/main/java/com/example/solidconnection/auth/service/signin/SignInService.java index 16ec4c484..ee63a02c3 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/signin/SignInService.java @@ -1,6 +1,9 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signin; +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.RefreshToken; import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.siteuser.domain.SiteUser; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/signup/EmailSignUpTokenProvider.java similarity index 93% rename from src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java rename to src/main/java/com/example/solidconnection/auth/service/signup/EmailSignUpTokenProvider.java index a3e2e5dc9..0f9d4281a 100644 --- a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/signup/EmailSignUpTokenProvider.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signup; import com.example.solidconnection.auth.dto.EmailSignUpTokenRequest; import com.example.solidconnection.common.exception.CustomException; @@ -27,6 +27,6 @@ public String issueEmailSignUpToken(EmailSignUpTokenRequest request) { } passwordTemporaryStorage.save(email, password); - return signUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.EMAIL); + return signUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.EMAIL).token(); } } diff --git a/src/main/java/com/example/solidconnection/auth/service/PasswordTemporaryStorage.java b/src/main/java/com/example/solidconnection/auth/service/signup/PasswordTemporaryStorage.java similarity index 84% rename from src/main/java/com/example/solidconnection/auth/service/PasswordTemporaryStorage.java rename to src/main/java/com/example/solidconnection/auth/service/signup/PasswordTemporaryStorage.java index adcb8bf68..df70465b6 100644 --- a/src/main/java/com/example/solidconnection/auth/service/PasswordTemporaryStorage.java +++ b/src/main/java/com/example/solidconnection/auth/service/signup/PasswordTemporaryStorage.java @@ -1,8 +1,7 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signup; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.token.config.TokenProperties; import java.util.Optional; -import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; @@ -16,14 +15,14 @@ public class PasswordTemporaryStorage { private final RedisTemplate redisTemplate; private final PasswordEncoder passwordEncoder; + private final TokenProperties tokenProperties; public void save(String email, String rawPassword) { String encodedPassword = passwordEncoder.encode(rawPassword); redisTemplate.opsForValue().set( convertToKey(email), encodedPassword, - TokenType.SIGN_UP.getExpireTime(), - TimeUnit.MILLISECONDS + tokenProperties.signUp().expireTime() ); } diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java similarity index 97% rename from src/main/java/com/example/solidconnection/auth/service/SignUpService.java rename to src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java index d6feed9e1..86415d913 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signup; import static com.example.solidconnection.common.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; @@ -6,6 +6,7 @@ import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.auth.service.signin.SignInService; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.location.country.service.InterestedCountryService; import com.example.solidconnection.location.region.service.InterestedRegionService; diff --git a/src/main/java/com/example/solidconnection/auth/service/signup/SignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpTokenProvider.java new file mode 100644 index 000000000..f108365f3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpTokenProvider.java @@ -0,0 +1,72 @@ +package com.example.solidconnection.auth.service.signup; + +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; + +import com.example.solidconnection.auth.domain.SignUpToken; +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.auth.service.TokenStorage; +import com.example.solidconnection.auth.token.config.TokenProperties; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SignUpTokenProvider { + + private static final String AUTH_TYPE_CLAIM_KEY = "authType"; + + private final TokenProvider tokenProvider; + private final TokenStorage tokenStorage; + private final TokenProperties tokenProperties; + + public SignUpToken generateAndSaveSignUpToken(String email, AuthType authType) { + Subject subject = new Subject(email); + String token = tokenProvider.generateToken( + subject, + Map.of(AUTH_TYPE_CLAIM_KEY, authType.toString()), + tokenProperties.signUp().expireTime() + ); + SignUpToken signUpToken = new SignUpToken(token); + return tokenStorage.saveToken(subject, signUpToken); + } + + public void deleteByEmail(String email) { + tokenStorage.deleteToken(new Subject(email), SignUpToken.class); + } + + public void validateSignUpToken(String token) { + validateFormatAndExpiration(token); + validateIssuedByServer(token); + } + + private void validateFormatAndExpiration(String token) { // subject와 claims가 파싱되는지, AuthType이 포함되어있는지 검증 + try { + tokenProvider.parseSubject(token); + String serializedAuthType = tokenProvider.parseClaims(token, AUTH_TYPE_CLAIM_KEY, String.class); + AuthType.valueOf(serializedAuthType); + } catch (Exception e) { + throw new CustomException(SIGN_UP_TOKEN_INVALID); + } + } + + private void validateIssuedByServer(String token) { + String email = parseEmail(token); + tokenStorage.findToken(new Subject(email), SignUpToken.class) + .filter(foundToken -> foundToken.equals(token)) + .orElseThrow(() -> new CustomException(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER)); + } + + public String parseEmail(String token) { + return tokenProvider.parseSubject(token).value(); + } + + public AuthType parseAuthType(String token) { + String serializedAuthType = tokenProvider.parseClaims(token, AUTH_TYPE_CLAIM_KEY, String.class); + return AuthType.valueOf(serializedAuthType); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/token/JwtTokenProvider.java b/src/main/java/com/example/solidconnection/auth/token/JwtTokenProvider.java index d7c968ccf..262cfdcd3 100644 --- a/src/main/java/com/example/solidconnection/auth/token/JwtTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/token/JwtTokenProvider.java @@ -2,18 +2,17 @@ import static com.example.solidconnection.common.exception.ErrorCode.INVALID_TOKEN; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.domain.Subject; import com.example.solidconnection.auth.service.TokenProvider; import com.example.solidconnection.auth.token.config.JwtProperties; import com.example.solidconnection.common.exception.CustomException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import java.time.Duration; import java.util.Date; import java.util.Map; -import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @Component @@ -21,23 +20,22 @@ public class JwtTokenProvider implements TokenProvider { private final JwtProperties jwtProperties; - private final RedisTemplate redisTemplate; @Override - public final String generateToken(String string, TokenType tokenType) { - return generateJwtTokenValue(string, Map.of(), tokenType.getExpireTime()); + public String generateToken(Subject subject, Duration expireTime) { + return generateJwtTokenValue(subject.value(), Map.of(), expireTime); } @Override - public String generateToken(String string, Map customClaims, TokenType tokenType) { - return generateJwtTokenValue(string, customClaims, tokenType.getExpireTime()); + public String generateToken(Subject subject, Map customClaims, Duration expireTime) { + return generateJwtTokenValue(subject.value(), customClaims, expireTime); } - private String generateJwtTokenValue(String subject, Map claims, long expireTime) { + private String generateJwtTokenValue(String subject, Map claims, Duration expireTime) { Claims jwtClaims = Jwts.claims().setSubject(subject); jwtClaims.putAll(claims); Date now = new Date(); - Date expiredDate = new Date(now.getTime() + expireTime); + Date expiredDate = new Date(now.getTime() + expireTime.toMillis()); return Jwts.builder() .setClaims(jwtClaims) .setIssuedAt(now) @@ -47,24 +45,20 @@ private String generateJwtTokenValue(String subject, Map claims, } @Override - public final String saveToken(String token, TokenType tokenType) { - String subject = parseSubject(token); - redisTemplate.opsForValue().set( - tokenType.addPrefix(subject), - token, - tokenType.getExpireTime(), - TimeUnit.MILLISECONDS - ); - return token; + public Subject parseSubject(String token) { + String subject = parseJwtClaims(token).getSubject(); + if (subject == null || subject.isBlank()) { + throw new CustomException(INVALID_TOKEN); + } + return new Subject(subject); } @Override - public String parseSubject(String token) { - return parseClaims(token).getSubject(); + public T parseClaims(String token, String claimName, Class claimType) { + return parseJwtClaims(token).get(claimName, claimType); } - @Override - public Claims parseClaims(String token) { + private Claims parseJwtClaims(String token) { try { return Jwts.parser() .setSigningKey(jwtProperties.secret()) diff --git a/src/main/java/com/example/solidconnection/auth/token/RedisTokenStorage.java b/src/main/java/com/example/solidconnection/auth/token/RedisTokenStorage.java new file mode 100644 index 000000000..291bf4ce1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/token/RedisTokenStorage.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.auth.token; + +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.domain.Token; +import com.example.solidconnection.auth.service.TokenStorage; +import com.example.solidconnection.auth.token.config.TokenProperties; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisTokenStorage implements TokenStorage { + + private final RedisTemplate redisTemplate; + + @Override + public T saveToken(Subject subject, T token) { + redisTemplate.opsForValue().set( + createKey(subject, token.getClass()), + token.token(), + TokenProperties.getExpireTime(token.getClass()) + ); + return token; + } + + @Override + public Optional findToken(Subject subject, Class tokenClass) { + String key = createKey(subject, tokenClass); + String foundTokenValue = redisTemplate.opsForValue().get(key); + if (foundTokenValue == null || foundTokenValue.isBlank()) { + return Optional.empty(); + } + return Optional.of(foundTokenValue); + } + + @Override + public void deleteToken(Subject subject, Class tokenClass) { + String key = createKey(subject, tokenClass); + redisTemplate.delete(key); + } + + private String createKey(Subject subject, Class tokenClass) { + return TokenProperties.getStorageKeyPrefix(tokenClass) + ":" + subject.value(); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/token/TokenBlackListService.java b/src/main/java/com/example/solidconnection/auth/token/TokenBlackListService.java index 7f208710c..a2636f383 100644 --- a/src/main/java/com/example/solidconnection/auth/token/TokenBlackListService.java +++ b/src/main/java/com/example/solidconnection/auth/token/TokenBlackListService.java @@ -1,8 +1,6 @@ package com.example.solidconnection.auth.token; -import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; - -import com.example.solidconnection.auth.service.AccessToken; +import com.example.solidconnection.auth.token.config.TokenProperties; import com.example.solidconnection.security.filter.BlacklistChecker; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; @@ -14,6 +12,7 @@ public class TokenBlackListService implements BlacklistChecker { private static final String SIGN_OUT_VALUE = "signOut"; + private final TokenProperties tokenProperties; private final RedisTemplate redisTemplate; /* @@ -21,14 +20,22 @@ public class TokenBlackListService implements BlacklistChecker { * - key = BLACKLIST:{accessToken} * - value = {SIGN_OUT_VALUE} -> key 의 존재만 확인하므로, value 에는 무슨 값이 들어가도 상관없다. * */ - public void addToBlacklist(AccessToken accessToken) { - String blackListKey = BLACKLIST.addPrefix(accessToken.token()); - redisTemplate.opsForValue().set(blackListKey, SIGN_OUT_VALUE); + public void addToBlacklist(String accessToken) { + String blackListKey = createKey(accessToken); + redisTemplate.opsForValue().set( + blackListKey, + SIGN_OUT_VALUE, + tokenProperties.blackList().expireTime() + ); } @Override public boolean isTokenBlacklisted(String accessToken) { - String blackListTokenKey = BLACKLIST.addPrefix(accessToken); - return redisTemplate.hasKey(blackListTokenKey); + String blackListKey = createKey(accessToken); + return redisTemplate.hasKey(blackListKey); + } + + private String createKey(String accessToken) { + return tokenProperties.blackList().storageKeyPrefix() + ":" + accessToken; } } diff --git a/src/main/java/com/example/solidconnection/auth/token/config/TokenConfig.java b/src/main/java/com/example/solidconnection/auth/token/config/TokenConfig.java new file mode 100644 index 000000000..07becbb1d --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/token/config/TokenConfig.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.token.config; + +import java.time.Duration; + +public record TokenConfig( + String storageKeyPrefix, + Duration expireTime +) { + +} diff --git a/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java b/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java new file mode 100644 index 000000000..cf8a6e9b4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.auth.token.config; + +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.RefreshToken; +import com.example.solidconnection.auth.domain.SignUpToken; +import com.example.solidconnection.auth.domain.Token; +import jakarta.annotation.PostConstruct; +import java.time.Duration; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "token") +public record TokenProperties( + TokenConfig access, + TokenConfig refresh, + TokenConfig signUp, + TokenConfig blackList +) { + + private static Map, TokenConfig> tokenConfigs; + + @PostConstruct + public void init() { + tokenConfigs = Map.of( + AccessToken.class, access, + RefreshToken.class, refresh, + SignUpToken.class, signUp + ); + } + + public static Duration getExpireTime(Class tokenClass) { + return tokenConfigs.get(tokenClass).expireTime(); + } + + public static String getStorageKeyPrefix(Class tokenClass) { + return tokenConfigs.get(tokenClass).storageKeyPrefix(); + } +} diff --git a/src/main/java/com/example/solidconnection/security/authentication/TokenAuthenticationProvider.java b/src/main/java/com/example/solidconnection/security/authentication/TokenAuthenticationProvider.java index d0c105884..74783922a 100644 --- a/src/main/java/com/example/solidconnection/security/authentication/TokenAuthenticationProvider.java +++ b/src/main/java/com/example/solidconnection/security/authentication/TokenAuthenticationProvider.java @@ -21,7 +21,7 @@ public Authentication authenticate(Authentication auth) throws AuthenticationExc TokenAuthentication tokenAuth = (TokenAuthentication) auth; String token = tokenAuth.getToken(); - String username = tokenProvider.parseSubject(token); + String username = tokenProvider.parseSubject(token).value(); SiteUserDetails userDetails = (SiteUserDetails) siteUserDetailsService.loadUserByUsername(username); return new TokenAuthentication(token, userDetails); } diff --git a/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java b/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java index a5924b860..57b6ad97e 100644 --- a/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java +++ b/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java @@ -6,7 +6,7 @@ import static org.mockito.BDDMockito.given; import com.example.solidconnection.auth.controller.config.RefreshTokenCookieProperties; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.token.config.TokenProperties; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.support.TestContainerSpringBootTest; @@ -32,6 +32,9 @@ class RefreshTokenCookieManagerTest { @Autowired private RefreshTokenCookieManager cookieManager; + @Autowired + private TokenProperties tokenProperties; + @MockBean private RefreshTokenCookieProperties refreshTokenCookieProperties; @@ -59,7 +62,7 @@ void setUp() { () -> assertThat(header).contains("HttpOnly"), () -> assertThat(header).contains("Secure"), () -> assertThat(header).contains("Path=/"), - () -> assertThat(header).contains("Max-Age=" + TokenType.REFRESH.getExpireTime() / 1000), + () -> assertThat(header).contains("Max-Age=" + tokenProperties.refresh().expireTime().toSeconds()), () -> assertThat(header).contains("Domain=" + domain), () -> assertThat(header).contains("SameSite=" + SameSite.LAX.attributeValue()) ); diff --git a/src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java index caedec489..98454da0f 100644 --- a/src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java @@ -5,7 +5,9 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.RefreshToken; +import com.example.solidconnection.auth.domain.Subject; import com.example.solidconnection.auth.dto.ReissueResponse; import com.example.solidconnection.auth.token.TokenBlackListService; import com.example.solidconnection.common.exception.CustomException; @@ -19,7 +21,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; @DisplayName("인증 서비스 테스트") @TestContainerSpringBootTest @@ -35,7 +36,7 @@ class AuthServiceTest { private TokenBlackListService tokenBlackListService; @Autowired - private RedisTemplate redisTemplate; + private TokenStorage tokenStorage; @Autowired private SiteUserFixture siteUserFixture; @@ -43,13 +44,18 @@ class AuthServiceTest { @Autowired private SiteUserRepository siteUserRepository; + @Autowired + private TokenProvider tokenProvider; + private SiteUser siteUser; private AccessToken accessToken; + private Subject expectedSubject; @BeforeEach void setUp() { siteUser = siteUserFixture.사용자(); accessToken = authTokenProvider.generateAccessToken(siteUser); + expectedSubject = tokenProvider.parseSubject(accessToken.token()); } @Test @@ -58,9 +64,8 @@ void setUp() { authService.signOut(accessToken.token()); // then - String refreshTokenKey = TokenType.REFRESH.addPrefix(accessToken.subject().value()); assertAll( - () -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isNull(), + () -> assertThat(tokenStorage.findToken(expectedSubject, RefreshToken.class)).isEmpty(), () -> assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isTrue() ); } @@ -72,11 +77,10 @@ void setUp() { // then LocalDate tomorrow = LocalDate.now().plusDays(1); - String refreshTokenKey = TokenType.REFRESH.addPrefix(accessToken.subject().value()); SiteUser actualSitUser = siteUserRepository.findById(siteUser.getId()).orElseThrow(); assertAll( () -> assertThat(actualSitUser.getQuitedAt()).isEqualTo(tomorrow), - () -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isNull(), + () -> assertThat(tokenStorage.findToken(expectedSubject, RefreshToken.class)).isEmpty(), () -> assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isTrue() ); } diff --git a/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java index 54dce4f68..54e5f236c 100644 --- a/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java @@ -3,16 +3,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.RefreshToken; +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; @TestContainerSpringBootTest @DisplayName("인증 토큰 제공자 테스트") @@ -22,18 +25,21 @@ class AuthTokenProviderTest { private AuthTokenProvider authTokenProvider; @Autowired - private RedisTemplate redisTemplate; + private TokenProvider tokenProvider; + + @Autowired + private TokenStorage tokenStorage; @Autowired private SiteUserFixture siteUserFixture; private SiteUser siteUser; - private String expectedSubject; + private Subject expectedSubject; @BeforeEach void setUp() { siteUser = siteUserFixture.사용자(); - expectedSubject = siteUser.getId().toString(); + expectedSubject = new Subject(siteUser.getId().toString()); } @Test @@ -42,10 +48,13 @@ void setUp() { AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); // then + String accessTokenValue = accessToken.token(); + Subject actualSubject = tokenProvider.parseSubject(accessTokenValue); + Role actualRole = authTokenProvider.parseSiteUser(accessTokenValue).getRole(); assertAll( - () -> assertThat(accessToken.subject().value()).isEqualTo(expectedSubject), - () -> assertThat(accessToken.role()).isEqualTo(siteUser.getRole()), - () -> assertThat(accessToken.token()).isNotNull() + () -> assertThat(accessTokenValue).isNotNull(), + () -> assertThat(actualSubject).isEqualTo(expectedSubject), + () -> assertThat(actualRole).isEqualTo(siteUser.getRole()) ); } @@ -55,14 +64,14 @@ class 리프레시_토큰을_제공한다 { @Test void 리프레시_토큰을_생성하고_저장한다() { // when - RefreshToken actualRefreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); // then - String refreshTokenKey = TokenType.REFRESH.addPrefix(expectedSubject); - String expectedRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey); + Subject actualSubject = tokenProvider.parseSubject(refreshToken.token()); + Optional savedRefreshToken = tokenStorage.findToken(expectedSubject, RefreshToken.class); assertAll( - () -> assertThat(actualRefreshToken.subject().value()).isEqualTo(expectedSubject), - () -> assertThat(actualRefreshToken.token()).isEqualTo(expectedRefreshToken) + () -> assertThat(savedRefreshToken).hasValue(refreshToken.token()), + () -> assertThat(actualSubject).isEqualTo(expectedSubject) ); } @@ -86,11 +95,10 @@ class 리프레시_토큰을_제공한다 { AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); // when - authTokenProvider.deleteRefreshTokenByAccessToken(accessToken); + authTokenProvider.deleteRefreshTokenByAccessToken(accessToken.token()); // then - String refreshTokenKey = TokenType.REFRESH.addPrefix(expectedSubject); - assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isNull(); + assertThat(tokenStorage.findToken(expectedSubject, RefreshToken.class)).isEmpty(); } } diff --git a/src/test/java/com/example/solidconnection/auth/service/JwtTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/JwtTokenProviderTest.java index 62655df2a..506019478 100644 --- a/src/test/java/com/example/solidconnection/auth/service/JwtTokenProviderTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/JwtTokenProviderTest.java @@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.domain.Subject; import com.example.solidconnection.auth.token.JwtTokenProvider; import com.example.solidconnection.auth.token.config.JwtProperties; import com.example.solidconnection.common.exception.CustomException; @@ -13,6 +13,7 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import java.time.Duration; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -20,7 +21,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; @DisplayName("토큰 제공자 테스트") @TestContainerSpringBootTest @@ -32,68 +32,56 @@ class JwtTokenProviderTest { @Autowired private JwtProperties jwtProperties; - @Autowired - private RedisTemplate redisTemplate; + private final Subject expectedSubject = new Subject("subject123"); + private final Duration expectedExpireTime = Duration.ofMinutes(10); @Nested class 토큰을_생성한다 { @Test void subject_만_있는_토큰을_생성한다() { - // given - String actualSubject = "subject123"; - TokenType actualTokenType = TokenType.ACCESS; - // when - String token = tokenProvider.generateToken(actualSubject, actualTokenType); + String token = tokenProvider.generateToken(expectedSubject, expectedExpireTime); // then - subject와 만료 시간이 일치하는지 검증 - Claims claims = tokenProvider.parseClaims(token); - long expectedExpireTime = claims.getExpiration().getTime() - claims.getIssuedAt().getTime(); + Subject actualSubject = tokenProvider.parseSubject(token); + Duration actualExpireTime = getActualExpireTime(token); assertAll( - () -> assertThat(claims.getSubject()).isEqualTo(actualSubject), - () -> assertThat(expectedExpireTime).isEqualTo(actualTokenType.getExpireTime()) + () -> assertThat(actualSubject).isEqualTo(expectedSubject), + () -> assertThat(actualExpireTime).isEqualTo(expectedExpireTime) ); } @Test void subject_와_claims_가_있는_토큰을_생성한다() { // given - String actualSubject = "subject123"; - Map customClaims = Map.of("key1", "value1", "key2", "value2"); - TokenType actualTokenType = TokenType.ACCESS; + String key1 = "key1"; + String value1 = "value1"; + String key2 = "key2"; + String value2 = "value2"; + Map customClaims = Map.of(key1, value1, key2, value2); // when - String token = tokenProvider.generateToken(actualSubject, customClaims, actualTokenType); + String token = tokenProvider.generateToken(expectedSubject, customClaims, expectedExpireTime); // then - subject와 커스텀 클레임이 일치하는지 검증 - Claims claims = tokenProvider.parseClaims(token); - long expectedExpireTime = claims.getExpiration().getTime() - claims.getIssuedAt().getTime(); + Subject actualSubject = tokenProvider.parseSubject(token); + Duration actualExpireTime = getActualExpireTime(token); assertAll( - () -> assertThat(claims.getSubject()).isEqualTo(actualSubject), - () -> assertThat(claims).containsAllEntriesOf(customClaims), - () -> assertThat(expectedExpireTime).isEqualTo(actualTokenType.getExpireTime()) + () -> assertThat(actualSubject).isEqualTo(expectedSubject), + () -> assertThat(actualExpireTime).isEqualTo(expectedExpireTime), + () -> assertThat(tokenProvider.parseClaims(token, key1, String.class)).isEqualTo(value1), + () -> assertThat(tokenProvider.parseClaims(token, key2, String.class)).isEqualTo(value2) ); } - } - @Test - void 토큰을_저장한다() { - // given - String subject = "subject123"; - TokenType tokenType = TokenType.ACCESS; - String token = tokenProvider.generateToken(subject, tokenType); - - // when - String savedToken = tokenProvider.saveToken(token, tokenType); - - // then - key: "{TokenType.Prefix}:subject", value: {token} 로 저장되어있는지 검증, 반환하는 값이 value와 같은지 검증 - String key = tokenType.addPrefix(subject); - String value = redisTemplate.opsForValue().get(key); - assertAll( - () -> assertThat(value).isEqualTo(token), - () -> assertThat(savedToken).isEqualTo(value) - ); + private Duration getActualExpireTime(String token) { + Claims claims = Jwts.parser() + .setSigningKey(jwtProperties.secret()) + .parseClaimsJws(token) + .getBody(); + return Duration.ofMillis(claims.getExpiration().getTime() - claims.getIssuedAt().getTime()); + } } @Nested @@ -102,14 +90,13 @@ class 토큰으로부터_subject_를_추출한다 { @Test void 유효한_토큰의_subject_를_추출한다() { // given - String subject = "subject000"; - String token = createValidToken(subject); + String token = tokenProvider.generateToken(expectedSubject, expectedExpireTime); // when - String extractedSubject = tokenProvider.parseSubject(token); + Subject actualSubject = tokenProvider.parseSubject(token); // then - assertThat(extractedSubject).isEqualTo(subject); + assertThat(actualSubject).isEqualTo(expectedSubject); } @Test @@ -123,60 +110,76 @@ class 토큰으로부터_subject_를_추출한다 { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } + + @Test + void subject_가_없는_토큰의_subject_를_추출하면_예외가_발생한다() { + // given + Claims claims = Jwts.claims(new HashMap<>()); + String subjectNotExistingToken = createExpiredToken(claims); + String subjectBlankToken = tokenProvider.generateToken(new Subject(" "), expectedExpireTime); + + // when, then + assertAll( + () -> assertThatCode(() -> tokenProvider.parseSubject(subjectNotExistingToken)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()), + () -> assertThatCode(() -> tokenProvider.parseSubject(subjectBlankToken)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()) + ); + } } @Nested class 토큰으로부터_claim_을_추출한다 { + private final String claimKey = "key"; + private final String claimValue = "value"; + @Test void 유효한_토큰의_claim_을_추출한다() { // given - String subject = "subject"; - String claimKey = "key"; - String claimValue = "value"; - Claims expectedClaims = Jwts.claims(new HashMap<>(Map.of(claimKey, claimValue))).setSubject(subject); - String token = createValidToken(expectedClaims); + String token = tokenProvider.generateToken( + expectedSubject, + Map.of(claimKey, claimValue), + expectedExpireTime + ); // when - Claims actualClaims = tokenProvider.parseClaims(token); + String actualClaimValue = tokenProvider.parseClaims(token, claimKey, String.class); // then - assertAll( - () -> assertThat(actualClaims.getSubject()).isEqualTo(subject), - () -> assertThat(actualClaims.get(claimKey)).isEqualTo(claimValue) - ); + assertThat(actualClaimValue).isEqualTo(claimValue); } @Test void 유효하지_않은_토큰의_claim_을_추출하면_예외가_발생한다() { // given - String subject = "subject"; - Claims expectedClaims = Jwts.claims().setSubject(subject); + Claims expectedClaims = Jwts.claims(new HashMap<>(Map.of(claimKey, claimValue))); String token = createExpiredToken(expectedClaims); // when - assertThatCode(() -> tokenProvider.parseClaims(token)) + assertThatCode(() -> tokenProvider.parseClaims(token, claimKey, String.class)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } - } - private String createValidToken(String subject) { - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000)) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) - .compact(); - } + @Test + void 존재하지_않는_claim_을_추출하면_null을_반환한다() { + // given + String token = tokenProvider.generateToken( + expectedSubject, + Map.of(claimKey, claimValue), + expectedExpireTime + ); + String nonExistentClaimKey = "nonExistentKey"; - private String createValidToken(Claims claims) { - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000)) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) - .compact(); + // when + String actualClaimValue = tokenProvider.parseClaims(token, nonExistentClaimKey, String.class); + + // then + assertThat(actualClaimValue).isNull(); + } } private String createExpiredToken(String subject) { diff --git a/src/test/java/com/example/solidconnection/auth/service/TokenBlackListServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/TokenBlackListServiceTest.java index 5267f88f3..74d6a9b7f 100644 --- a/src/test/java/com/example/solidconnection/auth/service/TokenBlackListServiceTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/TokenBlackListServiceTest.java @@ -1,10 +1,9 @@ package com.example.solidconnection.auth.service; -import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; import static org.assertj.core.api.Assertions.assertThat; import com.example.solidconnection.auth.token.TokenBlackListService; -import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.auth.token.config.TokenProperties; import com.example.solidconnection.support.TestContainerSpringBootTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -23,21 +22,23 @@ class TokenBlackListServiceTest { @Autowired private RedisTemplate redisTemplate; - private AccessToken accessToken; + @Autowired + private TokenProperties tokenProperties; + + private String accessToken; @BeforeEach void setUp() { - accessToken = new AccessToken("subject", Role.MENTEE, "token"); + accessToken = "accessToken"; } - @Test void 액세스_토큰을_블랙리스트에_추가한다() { // when tokenBlackListService.addToBlacklist(accessToken); // then - String blackListTokenKey = BLACKLIST.addPrefix(accessToken.token()); + String blackListTokenKey = tokenProperties.blackList().storageKeyPrefix() + ":" + accessToken; String foundBlackListToken = redisTemplate.opsForValue().get(blackListTokenKey); assertThat(foundBlackListToken).isNotNull(); } @@ -51,13 +52,13 @@ class 블랙리스트에_있는_토큰인지_확인한다 { tokenBlackListService.addToBlacklist(accessToken); // when, then - assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isTrue(); + assertThat(tokenBlackListService.isTokenBlacklisted(accessToken)).isTrue(); } @Test void 블랙리스트에_토큰이_없는_경우() { // when, then - assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isFalse(); + assertThat(tokenBlackListService.isTokenBlacklisted(accessToken)).isFalse(); } } } diff --git a/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/signin/EmailSignInServiceTest.java similarity index 97% rename from src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java rename to src/test/java/com/example/solidconnection/auth/service/signin/EmailSignInServiceTest.java index 04b6780ad..46c6d565a 100644 --- a/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/signin/EmailSignInServiceTest.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signin; import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; diff --git a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/signin/SignInServiceTest.java similarity index 65% rename from src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java rename to src/test/java/com/example/solidconnection/auth/service/signin/SignInServiceTest.java index da06aa3e4..957c5c3a1 100644 --- a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/signin/SignInServiceTest.java @@ -1,19 +1,22 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signin; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.domain.RefreshToken; +import com.example.solidconnection.auth.domain.Subject; import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.auth.service.TokenStorage; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; import java.time.LocalDate; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; @DisplayName("로그인 서비스 테스트") @TestContainerSpringBootTest @@ -26,18 +29,18 @@ class SignInServiceTest { private TokenProvider tokenProvider; @Autowired - private RedisTemplate redisTemplate; + private TokenStorage tokenStorage; @Autowired private SiteUserFixture siteUserFixture; private SiteUser user; - private String subject; + private Subject subject; @BeforeEach void setUp() { user = siteUserFixture.사용자(); - subject = user.getId().toString(); + subject = new Subject(user.getId().toString()); } @Test @@ -46,13 +49,13 @@ void setUp() { SignInResponse signInResponse = signInService.signIn(user); // then - String accessTokenSubject = tokenProvider.parseSubject(signInResponse.accessToken()); - String refreshTokenSubject = tokenProvider.parseSubject(signInResponse.refreshToken()); - String savedRefreshToken = redisTemplate.opsForValue().get(TokenType.REFRESH.addPrefix(refreshTokenSubject)); + Subject accessTokenSubject = tokenProvider.parseSubject(signInResponse.accessToken()); + Subject refreshTokenSubject = tokenProvider.parseSubject(signInResponse.refreshToken()); + Optional savedRefreshToken = tokenStorage.findToken(subject, RefreshToken.class); assertAll( () -> assertThat(accessTokenSubject).isEqualTo(subject), () -> assertThat(refreshTokenSubject).isEqualTo(subject), - () -> assertThat(savedRefreshToken).isEqualTo(signInResponse.refreshToken())); + () -> assertThat(savedRefreshToken).hasValue(signInResponse.refreshToken())); } @Test diff --git a/src/test/java/com/example/solidconnection/auth/service/PasswordTemporaryStorageTest.java b/src/test/java/com/example/solidconnection/auth/service/signup/PasswordTemporaryStorageTest.java similarity index 96% rename from src/test/java/com/example/solidconnection/auth/service/PasswordTemporaryStorageTest.java rename to src/test/java/com/example/solidconnection/auth/service/signup/PasswordTemporaryStorageTest.java index ea3ed6355..2a56f94b3 100644 --- a/src/test/java/com/example/solidconnection/auth/service/PasswordTemporaryStorageTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/signup/PasswordTemporaryStorageTest.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signup; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/signup/SignUpTokenProviderTest.java similarity index 61% rename from src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java rename to src/test/java/com/example/solidconnection/auth/service/signup/SignUpTokenProviderTest.java index c75eac5f5..aff6a50d4 100644 --- a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java +++ b/src/test/java/com/example/solidconnection/auth/service/signup/SignUpTokenProviderTest.java @@ -1,28 +1,33 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.signup; import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; import static com.example.solidconnection.common.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static java.util.Optional.empty; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; -import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.domain.SignUpToken; +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.auth.service.TokenStorage; import com.example.solidconnection.auth.token.config.JwtProperties; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.support.TestContainerSpringBootTest; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import java.time.Duration; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.boot.test.mock.mockito.SpyBean; @TestContainerSpringBootTest @DisplayName("회원가입 토큰 제공자 테스트") @@ -34,30 +39,30 @@ class SignUpTokenProviderTest { @Autowired private TokenProvider tokenProvider; - @Autowired - private RedisTemplate redisTemplate; + @SpyBean + private TokenStorage tokenStorage; @Autowired private JwtProperties jwtProperties; private final String authTypeClaimKey = "authType"; private final String email = "test@email.com"; + private final Subject subject = new Subject(email); private final AuthType authType = AuthType.KAKAO; @Test void 회원가입_토큰을_생성하고_저장한다() { // when - String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType); + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType).token(); // then - Claims claims = tokenProvider.parseClaims(signUpToken); - String actualSubject = claims.getSubject(); - AuthType actualAuthType = AuthType.valueOf(claims.get(authTypeClaimKey, String.class)); - String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); + Subject actualSubject = tokenProvider.parseSubject(signUpToken); + String actualAuthType = tokenProvider.parseClaims(signUpToken, authTypeClaimKey, String.class); + Optional actualSavedToken = tokenStorage.findToken(actualSubject, SignUpToken.class); assertAll( - () -> assertThat(actualSubject).isEqualTo(email), - () -> assertThat(actualAuthType).isEqualTo(authType), - () -> assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isEqualTo(signUpToken) + () -> assertThat(actualSubject.value()).isEqualTo(email), + () -> assertThat(actualAuthType).isEqualTo(authType.toString()), + () -> assertThat(actualSavedToken).hasValue(signUpToken) ); } @@ -70,8 +75,7 @@ class SignUpTokenProviderTest { signUpTokenProvider.deleteByEmail(email); // then - String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); - assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isNull(); + assertThat(tokenStorage.findToken(subject, SignUpToken.class)).isEmpty(); } @Nested @@ -80,9 +84,7 @@ class 주어진_회원가입_토큰을_검증한다 { @Test void 검증_성공한다() { // given - Map claim = new HashMap<>(Map.of(authTypeClaimKey, authType)); - String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); - redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + String validToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType).token(); // when & then assertThatCode(() -> signUpTokenProvider.validateSignUpToken(validToken)).doesNotThrowAnyException(); @@ -114,8 +116,8 @@ class 주어진_회원가입_토큰을_검증한다 { void 정해진_형식에_맞지_않으면_예외가_발생한다_authType_클래스_불일치() { // given String wrongAuthType = "카카오"; - Map wrongClaim = new HashMap<>(Map.of(authTypeClaimKey, wrongAuthType)); - String wrongAuthTypeClaim = createBaseJwtBuilder().addClaims(wrongClaim).compact(); + Map wrongClaim = new HashMap<>(Map.of(authTypeClaimKey, wrongAuthType)); + String wrongAuthTypeClaim = tokenProvider.generateToken(subject, wrongClaim, Duration.ofMinutes(10)); // when & then assertThatCode(() -> signUpTokenProvider.validateSignUpToken(wrongAuthTypeClaim)) @@ -123,23 +125,11 @@ class 주어진_회원가입_토큰을_검증한다 { .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); } - @Test - void 정해진_형식에_맞지_않으면_예외가_발생한다_subject_누락() { - // given - Map claim = new HashMap<>(Map.of(authTypeClaimKey, authType)); - String noSubject = createBaseJwtBuilder().addClaims(claim).compact(); - - // when & then - assertThatCode(() -> signUpTokenProvider.validateSignUpToken(noSubject)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); - } - @Test void 우리_서버에_발급된_토큰이_아니면_예외가_발생한다() { // given - Map validClaim = new HashMap<>(Map.of(authTypeClaimKey, authType)); - String signUpToken = createBaseJwtBuilder().addClaims(validClaim).setSubject(email).compact(); + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType).token(); + given(tokenStorage.findToken(subject, SignUpToken.class)).willReturn(empty()); // when & then assertThatCode(() -> signUpTokenProvider.validateSignUpToken(signUpToken)) @@ -151,12 +141,10 @@ class 주어진_회원가입_토큰을_검증한다 { @Test void 회원가입_토큰에서_이메일을_추출한다() { // given - Map claim = Map.of(authTypeClaimKey, authType); - String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); - redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType).token(); // when - String extractedEmail = signUpTokenProvider.parseEmail(validToken); + String extractedEmail = signUpTokenProvider.parseEmail(signUpToken); // then assertThat(extractedEmail).isEqualTo(email); @@ -165,11 +153,10 @@ class 주어진_회원가입_토큰을_검증한다 { @Test void 회원가입_토큰에서_인증_타입을_추출한다() { // given - Map claim = Map.of(authTypeClaimKey, authType); - String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType).token(); // when - AuthType extractedAuthType = signUpTokenProvider.parseAuthType(validToken); + AuthType extractedAuthType = signUpTokenProvider.parseAuthType(signUpToken); // then assertThat(extractedAuthType).isEqualTo(authType); @@ -183,11 +170,4 @@ private String createExpiredToken() { .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) .compact(); } - - private JwtBuilder createBaseJwtBuilder() { - return Jwts.builder() - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000)) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()); - } } diff --git a/src/test/java/com/example/solidconnection/auth/token/RedisTokenStorageTest.java b/src/test/java/com/example/solidconnection/auth/token/RedisTokenStorageTest.java new file mode 100644 index 000000000..ff06979b2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/token/RedisTokenStorageTest.java @@ -0,0 +1,90 @@ +package com.example.solidconnection.auth.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.domain.Token; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("Redis 토큰 저장소 테스트") +class RedisTokenStorageTest { + + @Autowired + private RedisTokenStorage redisTokenStorage; + + @Autowired + private TokenProvider tokenProvider; + + private Subject subject; + private Token expectedToken; + private Class tokenClass; + + @BeforeEach + void setUp() { + subject = new Subject("subject123"); + expectedToken = new AccessToken(tokenProvider.generateToken(subject, Duration.ofMinutes(10))); + tokenClass = expectedToken.getClass(); + } + + @Test + void 토큰을_저장한다() { + // when + Token savedToken = redisTokenStorage.saveToken(subject, expectedToken); + + // then + Optional foundToken = redisTokenStorage.findToken(subject, tokenClass); + assertAll( + () -> assertThat(foundToken).hasValue(expectedToken.token()), + () -> assertThat(savedToken).isEqualTo(expectedToken) + ); + } + + @Nested + class 토큰을_조회한다 { + + @Test + void 저장된_토큰이_있으면_Optional에_담아_반한다() { + // given + redisTokenStorage.saveToken(subject, expectedToken); + + // when + Optional actualToken = redisTokenStorage.findToken(subject, tokenClass); + + // then + assertThat(actualToken).hasValue(expectedToken.token()); + } + + @Test + void 저장된_토큰이_없으면_빈_Optional을_반환한다() { + // when + Optional foundToken = redisTokenStorage.findToken(subject, tokenClass); + + // then + assertThat(foundToken).isEmpty(); + } + } + + @Test + void 토큰을_삭제한다() { + // given + redisTokenStorage.saveToken(subject, expectedToken); + + // when + redisTokenStorage.deleteToken(subject, tokenClass); + + // then + Optional foundToken = redisTokenStorage.findToken(subject, tokenClass); + assertThat(foundToken).isEmpty(); + } +} diff --git a/src/test/java/com/example/solidconnection/security/filter/SignOutCheckFilterTest.java b/src/test/java/com/example/solidconnection/security/filter/SignOutCheckFilterTest.java index 3d78f1307..2667671cd 100644 --- a/src/test/java/com/example/solidconnection/security/filter/SignOutCheckFilterTest.java +++ b/src/test/java/com/example/solidconnection/security/filter/SignOutCheckFilterTest.java @@ -1,12 +1,12 @@ package com.example.solidconnection.security.filter; -import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; import static com.example.solidconnection.common.exception.ErrorCode.USER_ALREADY_SIGN_OUT; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.spy; import com.example.solidconnection.auth.token.config.JwtProperties; +import com.example.solidconnection.auth.token.config.TokenProperties; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.support.TestContainerSpringBootTest; import io.jsonwebtoken.Jwts; @@ -37,6 +37,9 @@ class SignOutCheckFilterTest { @Autowired private JwtProperties jwtProperties; + @Autowired + private TokenProperties tokenProperties; + private HttpServletRequest request; private HttpServletResponse response; private FilterChain filterChain; @@ -58,7 +61,7 @@ void setUp() { // given String token = createToken(subject); request = createRequest(token); - String refreshTokenKey = BLACKLIST.addPrefix(token); + String refreshTokenKey = tokenProperties.blackList().storageKeyPrefix() + ":" + token; redisTemplate.opsForValue().set(refreshTokenKey, token); // when & then diff --git a/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java b/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java index b39c91ece..330b084dd 100644 --- a/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java +++ b/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java @@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.example.solidconnection.auth.service.AccessToken; +import com.example.solidconnection.auth.domain.AccessToken; import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ce5a848cb..9d255b46b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -86,5 +86,16 @@ cors: news: default-thumbnail-url: "default-thumbnail-url" token: + access: + storage-key-prefix: "ACCESS" + expire-time: 10m refresh: cookie-domain: "test.domain.com" + storage-key-prefix: "REFRESH" + expire-time: 10m + sign-up: + storage-key-prefix: "SIGN_UP" + expire-time: 10m + black-list: + storage-key-prefix: "BLACKLIST" + expire-time: 10m From 6da42bd888fd09f2635ce2d1baff1022ebcd599c Mon Sep 17 00:00:00 2001 From: Yeon <84384499+lsy1307@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:53:27 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A0=84=EC=86=A1=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#475)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 토픽 주소 변경 - topic/{roomId} -> topic/chat/{roomId} - 의미적 명확성을 위해 * feat: 메시지 전송 DTO 작성 * feat: 메시지 전송 Service 작성 * feat: 메시지 전송 Controller 작성 * chore: 메시지 전송에 대한 컨트롤러 어노테이션을 RestController에서 Controller로 변경 * chore: WebSocket 초기 연결을 위한 HTTP 핸드셰이크에서 인증을 수행하도록 * fix: 핸드셰이크 후 Principal을 WebSocket 세션에 전달하도록 수정 - 이에 컨트롤러 인자로 siteUserId를 받도록 하고, DTO에 senderId를 삭제한다. * fix: 컨트롤러 파라미터 인자로 Principal를 받고, 이후 SiteUserDetails에서 siteUserId를 추출하도록 변경 * fix: DTO를 통해 순환참조 문제 해결 * chore: 실제 구독 권한 TODO 구현 - 검증 로직이 핸들러에서 사용됨에 따라 발생하는 순환 참조를 막기 위해 Lazy 어노테이션을 사용한 생성자를 직접 작성 * chore: 코드 리포매팅 * chore: 미사용 SiteUserPrincipal 제거 외 - 정규표현식을 사용하여 채팅방 ID 추출 - DTO 검증 추가 - 구체화 클래스가 아닌 인터페이스 사용하도록 (DIP) - senderId가 siteUserId가 아니라 chatParticipantId로 설정되도록 변경 * feat: 이미지 업로드를 위한 S3 Controller 및 Service 추가, ImgType수정 * feat: DTO 추가, 수정 및 MessageType 추가 * feat: Controller, Service 구현 * feat: Test 코드 추가 * fix: 서브 모듈 커밋해시 수정 * refactor: addAttachment 메서드 추가 * refactor: 코드 포매팅 * refactor: 테스트 코드 컨벤션 수정 * refactor: setChatMessage 메서드 수정 --------- Co-authored-by: seonghyeok --- .../controller/ChatMessageController.java | 13 ++ .../chat/domain/ChatAttachment.java | 16 ++- .../chat/domain/ChatMessage.java | 7 +- .../chat/domain/MessageType.java | 6 + .../chat/dto/ChatImageSendRequest.java | 14 +++ .../chat/dto/ChatMessageSendResponse.java | 25 +++- .../chat/service/ChatService.java | 49 ++++++++ .../s3/controller/S3Controller.java | 9 ++ .../solidconnection/s3/domain/ImgType.java | 2 +- .../solidconnection/s3/service/S3Service.java | 6 +- src/main/resources/secret | 2 +- .../chat/service/ChatServiceTest.java | 116 +++++++++++++++++- 12 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/chat/domain/MessageType.java create mode 100644 src/main/java/com/example/solidconnection/chat/dto/ChatImageSendRequest.java diff --git a/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java b/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java index a7e158224..47bf9939a 100644 --- a/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java +++ b/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java @@ -1,5 +1,6 @@ package com.example.solidconnection.chat.controller; +import com.example.solidconnection.chat.dto.ChatImageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageSendRequest; import com.example.solidconnection.chat.service.ChatService; import com.example.solidconnection.security.authentication.TokenAuthentication; @@ -29,4 +30,16 @@ public void sendChatMessage( chatService.sendChatMessage(chatMessageSendRequest, siteUserDetails.getSiteUser().getId(), roomId); } + + @MessageMapping("/chat/{roomId}/image") + public void sendChatImage( + @DestinationVariable Long roomId, + @Valid @Payload ChatImageSendRequest chatImageSendRequest, + Principal principal + ) { + TokenAuthentication tokenAuthentication = (TokenAuthentication) principal; + SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal(); + + chatService.sendChatImage(chatImageSendRequest, siteUserDetails.getSiteUser().getId(), roomId); + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java index def9263c8..da9c917ba 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; import lombok.Getter; @@ -30,7 +31,7 @@ public class ChatAttachment extends BaseEntity { @Column(length = 500) private String thumbnailUrl; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) private ChatMessage chatMessage; public ChatAttachment(boolean isImage, String url, String thumbnailUrl, ChatMessage chatMessage) { @@ -42,4 +43,17 @@ public ChatAttachment(boolean isImage, String url, String thumbnailUrl, ChatMess chatMessage.getChatAttachments().add(this); } } + + protected void setChatMessage(ChatMessage chatMessage) { + if (this.chatMessage == chatMessage) return; + + if (this.chatMessage != null) { + this.chatMessage.getChatAttachments().remove(this); + } + + this.chatMessage = chatMessage; + if (chatMessage != null && !chatMessage.getChatAttachments().contains(this)) { + chatMessage.getChatAttachments().add(this); + } + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java index 07fc99131..170a93f05 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -33,7 +33,7 @@ public class ChatMessage extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private ChatRoom chatRoom; - @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true) private final List chatAttachments = new ArrayList<>(); public ChatMessage(String content, long senderId, ChatRoom chatRoom) { @@ -44,4 +44,9 @@ public ChatMessage(String content, long senderId, ChatRoom chatRoom) { chatRoom.getChatMessages().add(this); } } + + public void addAttachment(ChatAttachment attachment) { + this.chatAttachments.add(attachment); + attachment.setChatMessage(this); + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/MessageType.java b/src/main/java/com/example/solidconnection/chat/domain/MessageType.java new file mode 100644 index 000000000..6e2750f38 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/domain/MessageType.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.chat.domain; + +public enum MessageType { + TEXT, + IMAGE, +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatImageSendRequest.java b/src/main/java/com/example/solidconnection/chat/dto/ChatImageSendRequest.java new file mode 100644 index 000000000..e32be3633 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatImageSendRequest.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.chat.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record ChatImageSendRequest( + @NotNull(message = "이미지 URL 목록은 필수입니다") + @Size(min = 1, max = 10, message = "이미지는 1~10개까지 가능합니다") + List<@NotBlank(message = "이미지 URL은 필수입니다") String> imageUrls +) { + +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java index 065c7ba1c..8e976148a 100644 --- a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java @@ -1,19 +1,38 @@ package com.example.solidconnection.chat.dto; import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.MessageType; +import java.util.List; public record ChatMessageSendResponse( long messageId, String content, - long senderId + long senderId, + MessageType messageType, + List attachments ) { public static ChatMessageSendResponse from(ChatMessage chatMessage) { + MessageType messageType = chatMessage.getChatAttachments().isEmpty() + ? MessageType.TEXT + : MessageType.IMAGE; + + List attachments = chatMessage.getChatAttachments().stream() + .map(attachment -> ChatAttachmentResponse.of( + attachment.getId(), + attachment.getIsImage(), + attachment.getUrl(), + attachment.getThumbnailUrl(), + attachment.getCreatedAt() + )) + .toList(); + return new ChatMessageSendResponse( chatMessage.getId(), chatMessage.getContent(), - chatMessage.getSenderId() + chatMessage.getSenderId(), + messageType, + attachments ); } - } diff --git a/src/main/java/com/example/solidconnection/chat/service/ChatService.java b/src/main/java/com/example/solidconnection/chat/service/ChatService.java index 1bd372d81..78530d752 100644 --- a/src/main/java/com/example/solidconnection/chat/service/ChatService.java +++ b/src/main/java/com/example/solidconnection/chat/service/ChatService.java @@ -5,10 +5,12 @@ import static com.example.solidconnection.common.exception.ErrorCode.INVALID_CHAT_ROOM_STATE; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; +import com.example.solidconnection.chat.domain.ChatAttachment; import com.example.solidconnection.chat.domain.ChatMessage; import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.domain.ChatRoom; import com.example.solidconnection.chat.dto.ChatAttachmentResponse; +import com.example.solidconnection.chat.dto.ChatImageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageResponse; import com.example.solidconnection.chat.dto.ChatMessageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageSendResponse; @@ -195,6 +197,53 @@ public void sendChatMessage(ChatMessageSendRequest chatMessageSendRequest, long simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse); } + @Transactional + public void sendChatImage(ChatImageSendRequest chatImageSendRequest, long siteUserId, long roomId) { + long senderId = chatParticipantRepository.findByChatRoomIdAndSiteUserId(roomId, siteUserId) + .orElseThrow(() -> new CustomException(CHAT_PARTICIPANT_NOT_FOUND)) + .getId(); + + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(INVALID_CHAT_ROOM_STATE)); + + ChatMessage chatMessage = new ChatMessage( + "", + senderId, + chatRoom + ); + + for (String imageUrl : chatImageSendRequest.imageUrls()) { + String thumbnailUrl = generateThumbnailUrl(imageUrl); + + ChatAttachment attachment = new ChatAttachment(true, imageUrl, thumbnailUrl, null); + chatMessage.addAttachment(attachment); + } + + chatMessageRepository.save(chatMessage); + + ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.from(chatMessage); + simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse); + } + + private String generateThumbnailUrl(String originalUrl) { + try { + String fileName = originalUrl.substring(originalUrl.lastIndexOf('/') + 1); + + String nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.')); + String extension = fileName.substring(fileName.lastIndexOf('.')); + + String thumbnailFileName = nameWithoutExt + "_thumb" + extension; + + String thumbnailUrl = originalUrl.replace("chat/images/", "chat/thumbnails/") + .replace(fileName, thumbnailFileName); + + return thumbnailUrl; + + } catch (Exception e) { + return originalUrl; + } + } + @Transactional public Long createMentoringChatRoom(Long mentoringId, Long mentorId, Long menteeId) { ChatRoom existingChatRoom = chatRoomRepository.findByMentoringId(mentoringId); diff --git a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java index 1bd978627..98b0574f9 100644 --- a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java +++ b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java @@ -5,6 +5,7 @@ import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.s3.dto.urlPrefixResponse; import com.example.solidconnection.s3.service.S3Service; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -68,6 +69,14 @@ public ResponseEntity uploadLanguageImage( return ResponseEntity.ok(profileImageUrl); } + @PostMapping("/chat") + public ResponseEntity> uploadChatImage( + @RequestParam("files") List imageFiles + ) { + List chatImageUrls = s3Service.uploadFiles(imageFiles, ImgType.CHAT); + return ResponseEntity.ok(chatImageUrls); + } + @GetMapping("/s3-url-prefix") public ResponseEntity getS3UrlPrefix() { return ResponseEntity.ok(new urlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded)); diff --git a/src/main/java/com/example/solidconnection/s3/domain/ImgType.java b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java index 7efedb1a5..50ac78b30 100644 --- a/src/main/java/com/example/solidconnection/s3/domain/ImgType.java +++ b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java @@ -4,7 +4,7 @@ @Getter public enum ImgType { - PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"), NEWS("news"); + PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"), NEWS("news"), CHAT("chat"); private final String type; diff --git a/src/main/java/com/example/solidconnection/s3/service/S3Service.java b/src/main/java/com/example/solidconnection/s3/service/S3Service.java index 11b66a499..4c4110693 100644 --- a/src/main/java/com/example/solidconnection/s3/service/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/service/S3Service.java @@ -35,7 +35,7 @@ public class S3Service { private static final Logger log = LoggerFactory.getLogger(S3Service.class); - private static final long MAX_FILE_SIZE_MB = 1024 * 1024 * 3; + private static final long MAX_FILE_SIZE_MB = 1024 * 1024 * 5; private final AmazonS3Client amazonS3; private final SiteUserRepository siteUserRepository; @@ -52,8 +52,8 @@ public class S3Service { * - 파일에 대한 메타 데이터를 생성한다. * - 임의의 랜덤한 문자열로 파일 이름을 생성한다. * - S3에 파일을 업로드한다. - * - 3mb 이상의 파일은 /origin/ 경로로 업로드하여 lambda 함수로 리사이징 진행한다. - * - 3mb 미만의 파일은 바로 업로드한다. + * - 5mb 이상의 파일은 /origin/ 경로로 업로드하여 lambda 함수로 리사이징 진행한다. + * - 5mb 미만의 파일은 바로 업로드한다. * */ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, ImgType imageFile) { // 파일 검증 diff --git a/src/main/resources/secret b/src/main/resources/secret index fd0d80ad2..bb3bf0f41 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit fd0d80ad28d28698e3e27160d9d27bf4e5462238 +Subproject commit bb3bf0f4122d10ddacab279a368cf9f06d6f6dbd diff --git a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java index 9f3c1f017..f5ec202bb 100644 --- a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java +++ b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java @@ -11,6 +11,8 @@ import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.domain.ChatReadStatus; import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.domain.MessageType; +import com.example.solidconnection.chat.dto.ChatImageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageResponse; import com.example.solidconnection.chat.dto.ChatMessageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageSendResponse; @@ -28,6 +30,7 @@ import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; import java.time.ZonedDateTime; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -433,7 +436,7 @@ void setUp() { } @Test - void 채팅_참여자가_아니면_메시지를_전송할_수_없다() { + void 채팅_참여자가_아니면_예외가_발생한다() { // given SiteUser nonParticipant = siteUserFixture.사용자(333, "nonParticipant"); ChatMessageSendRequest request = new ChatMessageSendRequest("안녕하세요"); @@ -444,4 +447,115 @@ void setUp() { .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); } } + + @Nested + class 채팅_이미지를_전송한다 { + + private SiteUser sender; + private ChatParticipant senderParticipant; + private ChatRoom chatRoom; + private static final String TEST_IMAGE_URL = "https://bucket.s3.ap-northeast-2.amazonaws.com/chat/images/example.jpg"; + private static final String TEST_IMAGE_URL2 = "https://bucket.s3.ap-northeast-2.amazonaws.com/chat/images/example2.jpg"; + private static final String EXPECTED_THUMBNAIL_URL = "https://bucket.s3.ap-northeast-2.amazonaws.com/chat/thumbnails/example_thumb.jpg"; + + @BeforeEach + void setUp() { + sender = siteUserFixture.사용자(111, "sender"); + chatRoom = chatRoomFixture.채팅방(false); + senderParticipant = chatParticipantFixture.참여자(sender.getId(), chatRoom); + } + + @Test + void 채팅방_참여자는_이미지_메시지를_전송할_수_있다() { + // given + final List imageUrls = List.of( + TEST_IMAGE_URL, + TEST_IMAGE_URL2 + ); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + + // then + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ChatMessageSendResponse.class); + + BDDMockito.verify(simpMessagingTemplate).convertAndSend(destinationCaptor.capture(), payloadCaptor.capture()); + + ChatMessageSendResponse response = payloadCaptor.getValue(); + + assertAll( + () -> assertThat(destinationCaptor.getValue()).isEqualTo("/topic/chat/" + chatRoom.getId()), + () -> assertThat(response.attachments()).hasSize(imageUrls.size()), + () -> assertThat(response.attachments().get(0).url()).isEqualTo(imageUrls.get(0)), + () -> assertThat(response.attachments().get(1).url()).isEqualTo(imageUrls.get(1)), + () -> assertThat(response.messageType()).isEqualTo(MessageType.IMAGE), + () -> assertThat(response.senderId()).isEqualTo(senderParticipant.getId()), + () -> assertThat(response.content()).isEmpty() + ); + } + + @Test + void 단일_이미지_메시지가_정상_전송된다() { + // given + final List imageUrls = List.of( + TEST_IMAGE_URL + ); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + + // then + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ChatMessageSendResponse.class); + BDDMockito.verify(simpMessagingTemplate).convertAndSend(BDDMockito.anyString(), payloadCaptor.capture()); + + ChatMessageSendResponse response = payloadCaptor.getValue(); + + assertAll( + () -> assertThat(response.attachments()).hasSize(1), + () -> assertThat(response.attachments().get(0).url()).isEqualTo(imageUrls.get(0)), + () -> assertThat(response.messageType()).isEqualTo(MessageType.IMAGE) + ); + } + + @Test + void 채팅_참여자가_아니면_예외가_발생한다() { + // given + SiteUser nonParticipant = siteUserFixture.사용자(333, "nonParticipant"); + List imageUrls = List.of(TEST_IMAGE_URL); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when & then + assertThatCode(() -> chatService.sendChatImage(request, nonParticipant.getId(), chatRoom.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + + @Test + void 썸네일_URL이_정상적으로_생성된다() { + // given + final List imageUrls = List.of( + TEST_IMAGE_URL + ); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + + // then + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ChatMessageSendResponse.class); + BDDMockito.verify(simpMessagingTemplate).convertAndSend(BDDMockito.anyString(), payloadCaptor.capture()); + + ChatMessageSendResponse response = payloadCaptor.getValue(); + + assertAll( + () -> assertThat(response.attachments().get(0).url()).isEqualTo(imageUrls.get(0)), + () -> assertThat(response.attachments().get(0).thumbnailUrl()).isEqualTo( + EXPECTED_THUMBNAIL_URL + ) + ); + } + } } From c758624f87e5b4b1c446cbc720f6fb193de6a2d5 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:11:57 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20DELE=20=EC=96=B4=ED=95=99=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EC=B6=94=EA=B0=80=20(#500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: DELE 어학 시험 enum 추가 - CEFR과 마찬가지로 A1 -> C2 순으로 높은 등급을 표현하므로 같은 비교자를 사용하도록 * feat: DELE 추가 DDL 작성 --- .../university/domain/LanguageTestType.java | 1 + .../migration/V31__add_DELE_Language_test.sql | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/main/resources/db/migration/V31__add_DELE_Language_test.sql diff --git a/src/main/java/com/example/solidconnection/university/domain/LanguageTestType.java b/src/main/java/com/example/solidconnection/university/domain/LanguageTestType.java index af0f861db..1bef27e30 100644 --- a/src/main/java/com/example/solidconnection/university/domain/LanguageTestType.java +++ b/src/main/java/com/example/solidconnection/university/domain/LanguageTestType.java @@ -8,6 +8,7 @@ public enum LanguageTestType { JLPT(Comparator.reverseOrder()), DALF(LanguageTestType::compareIntegerScores), DELF(LanguageTestType::compareIntegerScores), + DELE(String::compareTo), DUOLINGO(LanguageTestType::compareIntegerScores), IELTS(LanguageTestType::compareDoubleScores), NEW_HSK(LanguageTestType::compareIntegerScores), diff --git a/src/main/resources/db/migration/V31__add_DELE_Language_test.sql b/src/main/resources/db/migration/V31__add_DELE_Language_test.sql new file mode 100644 index 000000000..40dc106c3 --- /dev/null +++ b/src/main/resources/db/migration/V31__add_DELE_Language_test.sql @@ -0,0 +1,17 @@ +ALTER TABLE application + MODIFY COLUMN language_test_type ENUM( + 'CEFR', + 'DALF', + 'DELF', + 'DELE', + 'DUOLINGO', + 'IELTS', + 'JLPT', + 'NEW_HSK', + 'TCF', + 'TEF', + 'TOEFL_IBT', + 'TOEFL_ITP', + 'TOEIC', + 'ETC' + ); From 227992434f32dabb8a8634602ab0cbad5c4e9dc1 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:24:54 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix:=20alloy=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev.yml | 2 +- docker-compose.prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a37ef4c55..923b00d3c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -41,6 +41,6 @@ services: - "12345:12345" volumes: - ./logs:/var/log/spring - - ./docs/config.alloy:/etc/alloy/config.alloy:ro + - ./docs/infra-config/config.alloy:/etc/alloy/config.alloy:ro environment: - ALLOY_ENV=dev diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 30b0c9fc1..73d7f524e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -40,6 +40,6 @@ services: - "12345:12345" volumes: - ./logs:/var/log/spring - - ./docs/config.alloy:/etc/alloy/config.alloy:ro + - ./docs/infra-config/config.alloy:/etc/alloy/config.alloy:ro environment: - ALLOY_ENV=production From dcf54ae3fca969656d26c82178095b1e30c77d8b Mon Sep 17 00:00:00 2001 From: Yeon <84384499+lsy1307@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:11:16 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=B0=B8=EC=A1=B0=20commit=20hash=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=20=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/secret | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/secret b/src/main/resources/secret index bb3bf0f41..fd0d80ad2 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit bb3bf0f4122d10ddacab279a368cf9f06d6f6dbd +Subproject commit fd0d80ad28d28698e3e27160d9d27bf4e5462238 From 4329673da438b6a257e4b31d55373c919c1f668e Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:26:00 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/secret | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/secret b/src/main/resources/secret index fd0d80ad2..e15e2b020 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit fd0d80ad28d28698e3e27160d9d27bf4e5462238 +Subproject commit e15e2b020d19532adde2afe570203ab03287614d From 1cd50d486607e81cfdf5817fcc2393bf085219e4 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Thu, 18 Sep 2025 23:48:14 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20Nginx=EA=B0=80=20WebSocket=20Hands?= =?UTF-8?q?hake=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EC=98=AC=EB=B0=94?= =?UTF-8?q?=EB=A5=B4=EA=B2=8C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: nginx가 웹소켓 핸드셰이크 요청을 제대로 처리하도록 수정 * chore: 변경된 nginx.conf가 cd 단계에서 개발 서버에 반영되도록 - 서버에 존재하는 default를 nginx.conf에 통합 * test: 테스트 코드 수정 - 초기 핸드셰이크는 ws가 아니라 http 프로토콜을 사용함 - 핸드셰이크를 테스트하므로 목적에 맞게 클래스 이름 변경 * chore: docker-compose down 시 명시적인 yml을 사용하도록 * chore: nginx conf 파일 환경 분리 * chore: prod 환경에서 cd 스크립트 수행 시 nginx conf 파일이 적용되도록 - docker compose down 시 명시적으로 yml 파일 지정 --- .github/workflows/dev-cd.yml | 18 ++++++++-- .github/workflows/prod-cd.yml | 18 ++++++++-- .../{nginx.conf => nginx.dev.conf} | 13 ++++--- docs/infra-config/nginx.prod.conf | 36 +++++++++++++++++++ ...nTest.java => WebSocketHandshakeTest.java} | 6 ++-- 5 files changed, 80 insertions(+), 11 deletions(-) rename docs/infra-config/{nginx.conf => nginx.dev.conf} (73%) create mode 100644 docs/infra-config/nginx.prod.conf rename src/test/java/com/example/solidconnection/websocket/{WebSocketStompIntegrationTest.java => WebSocketHandshakeTest.java} (95%) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 4269f9d46..f0d6d3cb0 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -71,7 +71,17 @@ jobs: source: "./docs/infra-config/config.alloy" target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" - - name: Run docker compose + - name: Copy nginx config to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USERNAME }} + key: ${{ secrets.DEV_PRIVATE_KEY }} + source: "./docs/infra-config/nginx.dev.conf" + target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/nginx" + rename: "default.conf" + + - name: Run docker compose and apply nginx config uses: appleboy/ssh-action@master with: host: ${{ secrets.DEV_HOST }} @@ -79,6 +89,10 @@ jobs: key: ${{ secrets.DEV_PRIVATE_KEY }} script_stop: true script: | + sudo cp /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/nginx/default.conf /etc/nginx/conf.d/default.conf + sudo nginx -t + sudo nginx -s reload + cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev - docker compose down + docker compose -f docker-compose.dev.yml down docker compose -f docker-compose.dev.yml up -d --build diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 714aede30..d52c524c9 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -71,7 +71,17 @@ jobs: source: "./docs/infra-config/config.alloy" target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" - - name: Run docker compose + - name: Copy nginx config to remote + uses: appleboy/scp-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PRIVATE_KEY }} + source: "./docs/infra-config/nginx.prod.conf" + target: "/home/${{ secrets.USERNAME }}/solid-connection-prod/nginx" + rename: "default.conf" + + - name: Run docker compose and apply nginx config uses: appleboy/ssh-action@master with: host: ${{ secrets.HOST }} @@ -79,6 +89,10 @@ jobs: key: ${{ secrets.PRIVATE_KEY }} script_stop: true script: | + sudo cp /home/${{ secrets.USERNAME }}/solid-connection-prod/nginx/default.conf /etc/nginx/conf.d/default.conf + sudo nginx -t + sudo nginx -s reload + cd /home/${{ secrets.USERNAME }}/solid-connect-server - docker compose down + docker compose -f docker-compose.prod.yml down docker compose -f docker-compose.prod.yml up -d --build diff --git a/docs/infra-config/nginx.conf b/docs/infra-config/nginx.dev.conf similarity index 73% rename from docs/infra-config/nginx.conf rename to docs/infra-config/nginx.dev.conf index 303463bce..d683cf677 100644 --- a/docs/infra-config/nginx.conf +++ b/docs/infra-config/nginx.dev.conf @@ -1,5 +1,6 @@ server { listen 80; + server_name api.stage.solid-connection.com; # http를 사용하는 경우 주석 해제 # location / { @@ -17,9 +18,10 @@ server { server { listen 443 ssl; + server_name api.stage.solid-connection.com; - ssl_certificate /etc/letsencrypt/live/api.solid-connection.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/api.solid-connection.com/privkey.pem; + ssl_certificate /etc/letsencrypt/live/api.stage.solid-connection.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.stage.solid-connection.com/privkey.pem; client_max_body_size 10M; ssl_protocols TLSv1.2 TLSv1.3; @@ -31,10 +33,13 @@ server { ssl_stapling_verify on; location / { - proxy_pass http://solid-connection-server:8080; + proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; } -} \ No newline at end of file +} diff --git a/docs/infra-config/nginx.prod.conf b/docs/infra-config/nginx.prod.conf new file mode 100644 index 000000000..abe128067 --- /dev/null +++ b/docs/infra-config/nginx.prod.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name api.solid-connection.com; + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name api.solid-connection.com; + + ssl_certificate /etc/letsencrypt/live/api.solid-connection.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.solid-connection.com/privkey.pem; + client_max_body_size 10M; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; # 클라이언트 보다 서버의 암호화 알고리즘을 우선하도록 설정 + ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"; + ssl_session_cache shared:SSL:10m; # SSL 세션 캐시 설정 + ssl_session_timeout 10m; + ssl_stapling on; # OCSP 스테이플링 활성화 + ssl_stapling_verify on; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java b/src/test/java/com/example/solidconnection/websocket/WebSocketHandshakeTest.java similarity index 95% rename from src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java rename to src/test/java/com/example/solidconnection/websocket/WebSocketHandshakeTest.java index 330b084dd..2bde3c0c6 100644 --- a/src/test/java/com/example/solidconnection/websocket/WebSocketStompIntegrationTest.java +++ b/src/test/java/com/example/solidconnection/websocket/WebSocketHandshakeTest.java @@ -30,8 +30,8 @@ import org.springframework.web.socket.sockjs.client.WebSocketTransport; @TestContainerSpringBootTest -@DisplayName("WebSocket/STOMP 통합 테스트") -class WebSocketStompIntegrationTest { +@DisplayName("WebSocket Handshake 테스트") +class WebSocketHandshakeTest { @LocalServerPort private int port; @@ -47,7 +47,7 @@ class WebSocketStompIntegrationTest { @BeforeEach void setUp() { - this.url = String.format("ws://localhost:%d/connect", port); + this.url = String.format("http://localhost:%d/connect", port); List transports = List.of(new WebSocketTransport(new StandardWebSocketClient())); this.stompClient = new WebSocketStompClient(new SockJsClient(transports)); this.stompClient.setMessageConverter(new MappingJackson2MessageConverter()); From ff6f22eb11084f96bcb3c3a572c3e87f2ff8ded4 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:56:10 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B4=80=EB=A0=A8=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20DDL=20=EC=9E=91=EC=84=B1=20(#507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 사용자 차단 엔티티 작성 * feat: 사용자 차단 테이블 DDL 작성 --- .../siteuser/domain/UserBlock.java | 37 +++++++++++++++++++ .../migration/V32__add_user_block_table.sql | 12 ++++++ 2 files changed, 49 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java create mode 100644 src/main/resources/db/migration/V32__add_user_block_table.sql diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java new file mode 100644 index 000000000..2e88ff7cf --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.siteuser.domain; + +import com.example.solidconnection.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@AllArgsConstructor +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_user_block_blocker_id_blocked_id", + columnNames = {"blocker_id", "blocked_id"} + ) +}) +public class UserBlock extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "blocker_id", nullable = false) + private long blockerId; + + @Column(name = "blocked_id", nullable = false) + private long blockedId; +} diff --git a/src/main/resources/db/migration/V32__add_user_block_table.sql b/src/main/resources/db/migration/V32__add_user_block_table.sql new file mode 100644 index 000000000..3983d1b85 --- /dev/null +++ b/src/main/resources/db/migration/V32__add_user_block_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE user_block +( + id BIGINT NOT NULL AUTO_INCREMENT, + blocker_id BIGINT NOT NULL, + blocked_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_user_block_blocker_id_blocked_id UNIQUE (blocker_id, blocked_id), + CONSTRAINT fk_user_block_blocker_id FOREIGN KEY (blocker_id) REFERENCES site_user (id), + CONSTRAINT fk_user_block_blocked_id FOREIGN KEY (blocked_id) REFERENCES site_user (id) +); From 10969ddcd4e6c07d91bac6d14ade545b3cbf8ac6 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:58:23 +0900 Subject: [PATCH 13/14] =?UTF-8?q?chore:=20CODEOWNERS=EC=97=90=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EC=9A=B4=20=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e0d720750..f9840b68f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @Gyuhyeok99 @nayonsoso @wibaek @whqtker @lsy1307 +* @Gyuhyeok99 @wibaek @whqtker @lsy1307 @Hexeong @JAEHEE25 @sukangpunch From 17aa696d955be6d2507a9556cc21e429c7a86f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EA=B7=9C=ED=98=81?= <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sat, 27 Sep 2025 22:46:33 +0900 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20=EC=B0=A8=EB=8B=A8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20api=20=EA=B5=AC=ED=98=84=20(#513)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유저 차단 api 추가 - 커뮤니티 로직 수정 필요 * teat: 유저 차단 api 테스트 추가 * feat: 유저 차단 취소 api 추가 * test: 유저 차단 관련 fixture 추가 * test: 유저 차단 취소 api 테스트 추가 * feat: 유저 차단 조회 api 추가 * test: 유저 차단 조회 api 테스트 추가 * feat: 차단한 유저의 게시글은 보이지 않도록 수정 - 커뮤니티 목록은 로그인하지 않더라도 보이게 수정 * feat: 차단한 유저의 게시글 댓글도 보이지 않도록 수정 - 차단한 댓글이면 대댓글은 차단하지 않았더라도 보이지 않도록 함 * test: 코드리뷰 반영 - 인자 순서 변경 * style: 불필요한 개행 제거 * style: 불필요한 개행 제거 * style: 함수명 명확하게 변경 * style: 개행 컨벤션 준수 * refactor: validated 함수 분리 - 불필요한 지역변수 제거 --- .../common/exception/ErrorCode.java | 5 + .../board/controller/BoardController.java | 4 +- .../comment/repository/CommentRepository.java | 44 +++--- .../comment/service/CommentService.java | 2 +- .../post/repository/PostRepository.java | 9 ++ .../post/service/PostQueryService.java | 21 ++- .../controller/SiteUserController.java | 36 +++++ .../siteuser/domain/UserBlock.java | 5 + .../siteuser/dto/UserBlockResponse.java | 12 ++ .../repository/UserBlockRepository.java | 27 ++++ .../siteuser/service/SiteUserService.java | 53 ++++++++ .../comment/service/CommentServiceTest.java | 31 +++++ .../post/service/PostQueryServiceTest.java | 58 +++++++- .../siteuser/fixture/UserBlockFixture.java | 19 +++ .../fixture/UserBlockFixtureBuilder.java | 35 +++++ .../siteuser/service/SiteUserServiceTest.java | 127 ++++++++++++++++++ 16 files changed, 460 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/siteuser/dto/UserBlockResponse.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java create mode 100644 src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixture.java create mode 100644 src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixtureBuilder.java diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 4d135416e..fbd110f9d 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -47,6 +47,7 @@ public enum ErrorCode { REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 신고 대상입니다."), CHAT_PARTNER_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "채팅 상대를 찾을 수 없습니다."), CHAT_PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "채팅 참여자를 찾을 수 없습니다."), + BLOCK_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "차단 대상 사용자를 찾을 수 없습니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -126,6 +127,10 @@ public enum ErrorCode { // report ALREADY_REPORTED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 신고한 상태입니다."), + // block + ALREADY_BLOCKED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 차단한 상태입니다."), + CANNOT_BLOCK_YOURSELF(HttpStatus.BAD_REQUEST.value(), "자기 자신을 차단할 수 없습니다."), + // chat INVALID_CHAT_ROOM_STATE(HttpStatus.BAD_REQUEST.value(), "잘못된 채팅방 상태입니다."), diff --git a/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java index 196a48239..89970d334 100644 --- a/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java +++ b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java @@ -33,11 +33,11 @@ public ResponseEntity findAccessibleCodes() { @GetMapping("/{code}") public ResponseEntity findPostsByCodeAndCategory( - @AuthorizedUser long siteUserId, // todo: '사용하지 않는 인자'로 인증된 유저만 접근하게 하기보다는, 다른 방식으로 접근하는것이 좋을 것 같다 + @AuthorizedUser(required = false) Long siteUserId, @PathVariable(value = "code") String code, @RequestParam(value = "category", defaultValue = "전체") String category) { List postsByCodeAndPostCategory = postQueryService - .findPostsByCodeAndPostCategory(code, category); + .findPostsByCodeAndPostCategory(code, category, siteUserId); return ResponseEntity.ok().body(postsByCodeAndPostCategory); } } diff --git a/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java index c05cf9bd6..39b42f49a 100644 --- a/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java @@ -12,25 +12,31 @@ public interface CommentRepository extends JpaRepository { @Query(value = """ - WITH RECURSIVE CommentTree AS ( - SELECT - id, parent_id, post_id, site_user_id, content, - created_at, updated_at, is_deleted, - 0 AS level, CAST(id AS CHAR(255)) AS path - FROM comment - WHERE post_id = :postId AND parent_id IS NULL - UNION ALL - SELECT - c.id, c.parent_id, c.post_id, c.site_user_id, c.content, - c.created_at, c.updated_at, c.is_deleted, - ct.level + 1, CONCAT(ct.path, '->', c.id) - FROM comment c - INNER JOIN CommentTree ct ON c.parent_id = ct.id - ) - SELECT * FROM CommentTree - ORDER BY path - """, nativeQuery = true) - List findCommentTreeByPostId(@Param("postId") Long postId); + WITH RECURSIVE CommentTree AS ( + SELECT + id, parent_id, post_id, site_user_id, content, + created_at, updated_at, is_deleted, + 0 AS level, CAST(id AS CHAR(255)) AS path + FROM comment + WHERE post_id = :postId AND parent_id IS NULL + AND site_user_id NOT IN ( + SELECT blocked_id FROM user_block WHERE blocker_id = :siteUserId + ) + UNION ALL + SELECT + c.id, c.parent_id, c.post_id, c.site_user_id, c.content, + c.created_at, c.updated_at, c.is_deleted, + ct.level + 1, CONCAT(ct.path, '->', c.id) + FROM comment c + INNER JOIN CommentTree ct ON c.parent_id = ct.id + WHERE c.site_user_id NOT IN ( + SELECT blocked_id FROM user_block WHERE blocker_id = :siteUserId + ) + ) + SELECT * FROM CommentTree + ORDER BY path + """, nativeQuery = true) + List findCommentTreeByPostIdExcludingBlockedUsers(@Param("postId") Long postId, @Param("siteUserId") Long siteUserId); default Comment getById(Long id) { return findById(id) diff --git a/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java index 81b6bb49b..a10be68a9 100644 --- a/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java +++ b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java @@ -40,7 +40,7 @@ public class CommentService { public List findCommentsByPostId(long siteUserId, Long postId) { SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - List allComments = commentRepository.findCommentTreeByPostId(postId); + List allComments = commentRepository.findCommentTreeByPostIdExcludingBlockedUsers(postId, siteUserId); List filteredComments = filterCommentsByDeletionRules(allComments); Set userIds = filteredComments.stream() diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index de16d8ab1..aad12fab1 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -16,6 +16,15 @@ public interface PostRepository extends JpaRepository { List findByBoardCode(String boardCode); + @Query(""" + SELECT p FROM Post p + WHERE p.boardCode = :boardCode + AND p.siteUserId NOT IN ( + SELECT ub.blockedId FROM UserBlock ub WHERE ub.blockerId = :siteUserId + ) + """) + List findByBoardCodeExcludingBlockedUsers(@Param("boardCode") String boardCode, @Param("siteUserId") Long siteUserId); + @EntityGraph(attributePaths = {"postImageList"}) Optional findPostById(Long id); diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java index 85657a8a7..9602cd454 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java @@ -1,5 +1,6 @@ package com.example.solidconnection.community.post.service; +import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_BOARD_CODE; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_CATEGORY; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; @@ -21,6 +22,7 @@ import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBlockRepository; import com.example.solidconnection.util.RedisUtils; import java.util.List; import java.util.Objects; @@ -38,18 +40,24 @@ public class PostQueryService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final SiteUserRepository siteUserRepository; + private final UserBlockRepository userBlockRepository; private final CommentService commentService; private final RedisService redisService; private final RedisUtils redisUtils; @Transactional(readOnly = true) - public List findPostsByCodeAndPostCategory(String code, String category) { + public List findPostsByCodeAndPostCategory(String code, String category, Long siteUserId) { String boardCode = validateCode(code); PostCategory postCategory = validatePostCategory(category); boardRepository.getByCode(boardCode); - List postList = postRepository.findByBoardCode(boardCode); + List postList; // todo : 추후 개선 필요(현재 최신순으로 응답나가지 않고 있음) + if (siteUserId != null) { + postList = postRepository.findByBoardCodeExcludingBlockedUsers(boardCode, siteUserId); + } else { + postList = postRepository.findByBoardCode(boardCode); + } return PostListResponse.from(getPostListByPostCategory(postList, postCategory)); } @@ -58,6 +66,9 @@ public PostFindResponse findPostById(long siteUserId, Long postId) { SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); Post post = postRepository.getByIdUsingEntityGraph(postId); + + validatedIsBlockedByMe(post, siteUser); + Boolean isOwner = getIsOwner(post, siteUser); Boolean isLiked = getIsLiked(post, siteUser); @@ -111,4 +122,10 @@ private List getPostListByPostCategory(List postList, PostCategory p .filter(post -> post.getCategory().equals(postCategory)) .collect(Collectors.toList()); } + + private void validatedIsBlockedByMe(Post post, SiteUser siteUser) { + if (userBlockRepository.existsByBlockerIdAndBlockedId(siteUser.getId(), post.getSiteUserId())) { + throw new CustomException(ACCESS_DENIED); + } + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java b/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java index 64d926eca..77dd01152 100644 --- a/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java +++ b/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java @@ -1,10 +1,19 @@ package com.example.solidconnection.siteuser.controller; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.resolver.AuthorizedUser; import com.example.solidconnection.siteuser.dto.NicknameExistsResponse; +import com.example.solidconnection.siteuser.dto.UserBlockResponse; import com.example.solidconnection.siteuser.service.SiteUserService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -23,4 +32,31 @@ public ResponseEntity checkNicknameExists( NicknameExistsResponse nicknameExistsResponse = siteUserService.checkNicknameExists(nickname); return ResponseEntity.ok(nicknameExistsResponse); } + + @GetMapping("/blocks") + public ResponseEntity> getBlockedUsers( + @AuthorizedUser long siteUserId, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + SliceResponse response = siteUserService.getBlockedUsers(siteUserId, pageable); + return ResponseEntity.ok(response); + } + + @PostMapping("/block/{blocked-id}") + public ResponseEntity blockUser( + @AuthorizedUser long siteUserId, + @PathVariable("blocked-id") Long blockedId + ) { + siteUserService.blockUser(siteUserId, blockedId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/block/{blocked-id}") + public ResponseEntity cancelUserBlock( + @AuthorizedUser long siteUserId, + @PathVariable("blocked-id") Long blockedId + ) { + siteUserService.cancelUserBlock(siteUserId, blockedId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java index 2e88ff7cf..9cd78795b 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBlock.java @@ -34,4 +34,9 @@ public class UserBlock extends BaseEntity { @Column(name = "blocked_id", nullable = false) private long blockedId; + + public UserBlock(long blockerId, long blockedId) { + this.blockerId = blockerId; + this.blockedId = blockedId; + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/UserBlockResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/UserBlockResponse.java new file mode 100644 index 000000000..2f307d984 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/UserBlockResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.siteuser.dto; + +import java.time.ZonedDateTime; + +public record UserBlockResponse( + long id, + long blockedId, + String nickname, + ZonedDateTime createdAt +) { + +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java new file mode 100644 index 000000000..28a88874a --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.siteuser.domain.UserBlock; +import com.example.solidconnection.siteuser.dto.UserBlockResponse; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserBlockRepository extends JpaRepository { + + boolean existsByBlockerIdAndBlockedId(long blockerId, long blockedId); + + Optional findByBlockerIdAndBlockedId(long blockerId, long blockedId); + + @Query(""" + SELECT new com.example.solidconnection.siteuser.dto.UserBlockResponse( + ub.id, ub.blockedId, su.nickname, ub.createdAt + ) + FROM UserBlock ub + JOIN SiteUser su ON ub.blockedId = su.id + WHERE ub.blockerId = :blockerId + """) + Slice findBlockedUsersWithNickname(@Param("blockerId") long blockerId, Pageable pageable); +} diff --git a/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java b/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java index e67d71ab8..7052a735a 100644 --- a/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java +++ b/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java @@ -1,18 +1,71 @@ package com.example.solidconnection.siteuser.service; +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_BLOCKED_BY_CURRENT_USER; +import static com.example.solidconnection.common.exception.ErrorCode.BLOCK_USER_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.CANNOT_BLOCK_YOURSELF; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.UserBlock; import com.example.solidconnection.siteuser.dto.NicknameExistsResponse; +import com.example.solidconnection.siteuser.dto.UserBlockResponse; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBlockRepository; +import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service public class SiteUserService { private final SiteUserRepository siteUserRepository; + private final UserBlockRepository userBlockRepository; public NicknameExistsResponse checkNicknameExists(String nickname) { boolean exists = siteUserRepository.existsByNickname(nickname); return NicknameExistsResponse.from(exists); } + + @Transactional(readOnly = true) + public SliceResponse getBlockedUsers(long siteUserId, Pageable pageable) { + Slice slice = userBlockRepository.findBlockedUsersWithNickname(siteUserId, pageable); + + List content = slice.getContent(); + return SliceResponse.of(content, slice); + } + + @Transactional + public void blockUser(long blockerId, long blockedId) { + validateBlockUser(blockerId, blockedId); + UserBlock userBlock = new UserBlock(blockerId, blockedId); + userBlockRepository.save(userBlock); + } + + private void validateBlockUser(long blockerId, long blockedId) { + if (Objects.equals(blockerId, blockedId)) { + throw new CustomException(CANNOT_BLOCK_YOURSELF); + } + if (!siteUserRepository.existsById(blockedId)) { + throw new CustomException(USER_NOT_FOUND); + } + if (userBlockRepository.existsByBlockerIdAndBlockedId(blockerId, blockedId)) { + throw new CustomException(ALREADY_BLOCKED_BY_CURRENT_USER); + } + } + + @Transactional + public void cancelUserBlock(long blockerId, long blockedId) { + if (!siteUserRepository.existsById(blockedId)) { + throw new CustomException(USER_NOT_FOUND); + } + UserBlock userBlock = userBlockRepository.findByBlockerIdAndBlockedId(blockerId, blockedId) + .orElseThrow(() -> new CustomException(BLOCK_USER_NOT_FOUND)); + userBlockRepository.delete(userBlock); + } } diff --git a/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java index c5219b036..63a824e53 100644 --- a/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java @@ -25,6 +25,7 @@ import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.fixture.UserBlockFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; import jakarta.transaction.Transactional; import java.util.List; @@ -56,6 +57,9 @@ class CommentServiceTest { @Autowired private CommentFixture commentFixture; + @Autowired + private UserBlockFixture userBlockFixture; + private SiteUser user1; private SiteUser user2; private Post post; @@ -187,6 +191,33 @@ class 댓글_조회_테스트 { .containsExactlyInAnyOrder(user2.getId(), user2.getId()) ); } + + @Test + void 차단한_사용자의_댓글은_제외된다() { + // given + userBlockFixture.유저_차단(user1.getId(), user2.getId()); + Comment parentComment1 = commentFixture.부모_댓글("부모 댓글1", post, user1); + Comment childComment1 = commentFixture.자식_댓글("자식 댓글1", post, user1, parentComment1); + Comment childComment2 = commentFixture.자식_댓글("자식 댓글2", post, user2, parentComment1); + Comment parentCommen2 = commentFixture.부모_댓글("부모 댓글2", post, user2); + Comment childComment3 = commentFixture.자식_댓글("자식 댓글1", post, user1, parentCommen2); + Comment childComment4 = commentFixture.자식_댓글("자식 댓글1", post, user1, parentCommen2); + + + // when + List responses = commentService.findCommentsByPostId(user1.getId(), post.getId()); + + // then + assertAll( + () -> assertThat(responses).hasSize(2), + () -> assertThat(responses) + .extracting(PostFindCommentResponse::id) + .containsExactly(parentComment1.getId(), childComment1.getId()), + () -> assertThat(responses) + .extracting(PostFindCommentResponse::id) + .doesNotContain(childComment2.getId(), parentCommen2.getId(), childComment3.getId(), childComment4.getId()) + ); + } } @Nested diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java index f5e1bb45b..beeb3fc88 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java @@ -1,8 +1,12 @@ package com.example.solidconnection.community.post.service; +import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.community.board.domain.Board; import com.example.solidconnection.community.board.domain.BoardCode; import com.example.solidconnection.community.board.fixture.BoardFixture; import com.example.solidconnection.community.comment.domain.Comment; @@ -15,6 +19,7 @@ import com.example.solidconnection.community.post.fixture.PostImageFixture; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.fixture.UserBlockFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.util.RedisUtils; import java.time.ZonedDateTime; @@ -52,6 +57,9 @@ class PostQueryServiceTest { @Autowired private CommentFixture commentFixture; + @Autowired + private UserBlockFixture userBlockFixture; + private SiteUser user; private Post post1; private Post post2; @@ -99,7 +107,8 @@ void setUp() { // when List actualResponses = postQueryService.findPostsByCodeAndPostCategory( BoardCode.FREE.name(), - PostCategory.자유.name() + PostCategory.자유.name(), + null ); // then @@ -121,7 +130,8 @@ void setUp() { // when List actualResponses = postQueryService.findPostsByCodeAndPostCategory( BoardCode.FREE.name(), - PostCategory.전체.name() + PostCategory.전체.name(), + null ); // then @@ -167,6 +177,20 @@ void setUp() { ); } + @Test + void 차단한_사용자의_게시글을_조회하면_예외가_발생한다() { + // given + SiteUser blockedUser = siteUserFixture.사용자(1, "blockedUser"); + userBlockFixture.유저_차단(user.getId(), blockedUser.getId()); + Board board = boardFixture.자유게시판(); + Post post = postFixture.게시글(board, blockedUser); + + // when & then + assertThatCode(() -> postQueryService.findPostById(user.getId(), post.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ACCESS_DENIED.getMessage()); + } + @Test void 게시글_목록_조회시_첫번째_이미지를_썸네일로_반환한다() { // given @@ -178,7 +202,8 @@ void setUp() { // when List actualResponses = postQueryService.findPostsByCodeAndPostCategory( BoardCode.FREE.name(), - PostCategory.전체.name() + PostCategory.전체.name(), + null ); // then @@ -195,7 +220,8 @@ void setUp() { // when List actualResponses = postQueryService.findPostsByCodeAndPostCategory( BoardCode.FREE.name(), - PostCategory.전체.name() + PostCategory.전체.name(), + null ); // then @@ -206,4 +232,28 @@ void setUp() { assertThat(postResponse.postThumbnailUrl()).isNull(); } + + @Test + void 차단한_사용자의_게시글은_제외된다() { + // given + SiteUser blockedUser = siteUserFixture.사용자(1, "blockedUser"); + SiteUser notBlockedUser = siteUserFixture.사용자(2, "notBlockedUser"); + userBlockFixture.유저_차단(user.getId(), blockedUser.getId()); + Board board = boardFixture.자유게시판(); + Post blockedPost = postFixture.게시글(board, blockedUser); + Post notBlockedPost = postFixture.게시글(board, notBlockedUser); + + // when + List response = postQueryService.findPostsByCodeAndPostCategory( + BoardCode.FREE.name(), + PostCategory.전체.name(), + user.getId() + ); + + // then + assertAll( + () -> assertThat(response).extracting(PostListResponse::id).contains(notBlockedPost.getId()), + () -> assertThat(response).extracting(PostListResponse::id).doesNotContain(blockedPost.getId()) + ); + } } diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixture.java new file mode 100644 index 000000000..cfb11b6e9 --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixture.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.UserBlock; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UserBlockFixture { + + private final UserBlockFixtureBuilder userBlockFixtureBuilder; + + public UserBlock 유저_차단(long blockerId, long blockedId) { + return userBlockFixtureBuilder.userBlock() + .blockerId(blockerId) + .blockedId(blockedId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixtureBuilder.java new file mode 100644 index 000000000..deb4e0642 --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBlockFixtureBuilder.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.UserBlock; +import com.example.solidconnection.siteuser.repository.UserBlockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UserBlockFixtureBuilder { + + private final UserBlockRepository userBlockRepository; + + private long blockerId; + private long blockedId; + + public UserBlockFixtureBuilder userBlock() { + return new UserBlockFixtureBuilder(userBlockRepository); + } + + public UserBlockFixtureBuilder blockerId(long blockerId) { + this.blockerId = blockerId; + return this; + } + + public UserBlockFixtureBuilder blockedId(long blockedId) { + this.blockedId = blockedId; + return this; + } + + public UserBlock create() { + UserBlock userBlock = new UserBlock(blockerId, blockedId); + return userBlockRepository.save(userBlock); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java index 3a81d40e2..6dd35d282 100644 --- a/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java @@ -1,16 +1,30 @@ package com.example.solidconnection.siteuser.service; +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_BLOCKED_BY_CURRENT_USER; +import static com.example.solidconnection.common.exception.ErrorCode.BLOCK_USER_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.CANNOT_BLOCK_YOURSELF; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserBlock; import com.example.solidconnection.siteuser.dto.NicknameExistsResponse; +import com.example.solidconnection.siteuser.dto.UserBlockResponse; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.fixture.UserBlockFixture; +import com.example.solidconnection.siteuser.repository.UserBlockRepository; import com.example.solidconnection.support.TestContainerSpringBootTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; @TestContainerSpringBootTest @DisplayName("유저 서비스 테스트") @@ -19,9 +33,15 @@ class SiteUserServiceTest { @Autowired private SiteUserService siteUserService; + @Autowired + private UserBlockRepository userBlockRepository; + @Autowired private SiteUserFixture siteUserFixture; + @Autowired + private UserBlockFixture userBlockFixture; + private SiteUser user; @BeforeEach @@ -50,4 +70,111 @@ class 닉네임_중복_검사 { assertThat(response.exists()).isFalse(); } } + + @Nested + class 유저_차단_조회 { + + private static final int NO_NEXT_PAGE_NUMBER = -1; + + private SiteUser blockedUser1; + private SiteUser blockedUser2; + private Pageable pageable; + + @BeforeEach + void setUp() { + blockedUser1 = siteUserFixture.사용자(1, "blockedUser1"); + blockedUser2 = siteUserFixture.사용자(2, "blockedUser2"); + pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + } + + @Test + void 최신순으로_차단한_사용자를_정상적으로_조회한다() { + // given + UserBlock userBlock1 = userBlockFixture.유저_차단(user.getId(), blockedUser1.getId()); + UserBlock userBlock2 = userBlockFixture.유저_차단(user.getId(), blockedUser2.getId()); + + // when + SliceResponse response = siteUserService.getBlockedUsers(user.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).hasSize(2), + () -> assertThat(response.content().get(0).id()).isEqualTo(userBlock2.getId()), + () -> assertThat(response.content().get(0).blockedId()).isEqualTo(blockedUser2.getId()), + () -> assertThat(response.content().get(1).id()).isEqualTo(userBlock1.getId()), + () -> assertThat(response.content().get(1).blockedId()).isEqualTo(blockedUser1.getId()) + ); + } + + @Test + void 차단한_사용자가_없으면_빈_목록을_반환한다() { + // when + SliceResponse response = siteUserService.getBlockedUsers(user.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).isEmpty(), + () -> assertThat(response.nextPageNumber()).isEqualTo(NO_NEXT_PAGE_NUMBER) + ); + } + } + + @Nested + class 유저_차단 { + + private SiteUser blockedUser; + + @BeforeEach + void setUp() { + blockedUser = siteUserFixture.사용자(1, "blockedUser"); + } + + @Test + void 성공적으로_유저를_차단한다() { + // when + siteUserService.blockUser(user.getId(), blockedUser.getId()); + + // then + assertThat(userBlockRepository.existsByBlockerIdAndBlockedId(user.getId(), blockedUser.getId())).isTrue(); + } + + @Test + void 자기_자신을_차단하면_예외가_발생한다() { + // when & then + assertThatCode(() -> siteUserService.blockUser(user.getId(), user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CANNOT_BLOCK_YOURSELF.getMessage()); + } + + @Test + void 이미_차단했으면_예외가_발생한다() { + // given + siteUserService.blockUser(user.getId(), blockedUser.getId()); + + // when & then + assertThatCode(() -> siteUserService.blockUser(user.getId(), blockedUser.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_BLOCKED_BY_CURRENT_USER.getMessage()); + } + + @Test + void 성공적으로_유저_차단을_취소한다() { + // given + userBlockFixture.유저_차단(user.getId(), blockedUser.getId()); + + // when + siteUserService.cancelUserBlock(user.getId(), blockedUser.getId()); + + // then + assertThat(userBlockRepository.existsByBlockerIdAndBlockedId(user.getId(), blockedUser.getId())).isFalse(); + } + + @Test + void 차단하지_않았으면_예외가_발생한다() { + // when & then + assertThatCode(() -> siteUserService.cancelUserBlock(user.getId(), blockedUser.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(BLOCK_USER_NOT_FOUND.getMessage()); + } + } }