From a25dc26d5d70ea2c1b5a6f84dc91cf5821eba9b6 Mon Sep 17 00:00:00 2001 From: chcch529 <146617430+chcch529@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:12:34 +0900 Subject: [PATCH 01/74] =?UTF-8?q?feat:=20=EC=A7=88=EB=AC=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=82=AD=EC=A0=9C=20api=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 질문 수정 api * test: 질문 수정 서비스 테스트 * feat: 질문 삭제 api * test: 참가자 권한 조회 리포 테스트 * test: 질문 삭제 서비스 테스트 * feat: 예외 구체화 * fix: 테스트 호출 메서드 변경 * fix: errorCode 넘버링 수정 * feat: 질문 삭제 시 답변도 삭제 * style: 개행 제거 --- .../join/answer/dao/AnswerRepository.java | 2 + .../join/answer/service/AnswerService.java | 4 + .../join/global/exception/ErrorCode.java | 2 + .../dao/ParticipantRepository.java | 10 ++ .../service/ParticipantReader.java | 4 + .../join/question/dao/QuestionRepository.java | 2 + .../join/question/domain/Question.java | 4 + .../question/dto/QuestionDeleteResponse.java | 11 ++ .../question/dto/QuestionUpdateResponse.java | 14 ++ .../question/service/QuestionService.java | 44 +++++ .../join/question/util/QuestionMapper.java | 17 ++ .../api/QuestionWebsocketController.java | 31 ++++ .../dao/ParticipantRepositoryTests.java | 34 +++- .../service/QuestionServiceTests.java | 160 +++++++++++++----- 14 files changed, 296 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java create mode 100644 src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index 9c94c4b..bf40e14 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -13,5 +13,7 @@ public interface AnswerRepository extends JpaRepository { boolean existsByQuestionIdAndMemberId(Long questionId, Long memberId); + void deleteByQuestionId(Long questionId); + void deleteByQuestionIn(List questions); } diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index 2570890..f018a76 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -71,6 +71,10 @@ public AnswerGetResponse getAnswer(Long roomId, Long questionId, Long memberId) return AnswerMapper.toAnswerGetResponse(answer, emojiCount, isEmojied); } + public void deleteByQuestion(Long questionId) { + answerRepository.deleteByQuestionId(questionId); + } + public void deleteByQuestionList(List questions) { answerRepository.deleteByQuestionIn(questions); } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index df1969d..51d7a33 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -30,6 +30,8 @@ public enum ErrorCode { NOT_FOUND_ROOM_QUESTION("QUESTION-001", "질문을 해당 방에서 찾을 수 없습니다.", NOT_FOUND), NOT_FOUND_QUESTION("QUESTION-002", "질문을 찾을 수 없습니다.", NOT_FOUND), + UNAUTHORIZED_EDIT_QUESTION("QUESTION-003", "작성자만 질문을 수정할 수 있습니다.", UNAUTHORIZED), + UNAUTHORIZED_DELETE_QUESTION("QUESTION-004", "작성자 및 관리자만 질문을 삭제할 수 있습니다.", UNAUTHORIZED), UNAUTHORIZED_ROLE_ANSWER("ANSWER-001", "팀원 또는 발표자만 댓글을 작성할 수 있습니다.", UNAUTHORIZED), NOT_FOUND_EXIST_ANSWER("ANSWER-002", "해당 질문에 대한 답변이 존재하지 않습니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java index 479f4c2..08de9b6 100644 --- a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java +++ b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java @@ -61,5 +61,15 @@ Page findByMemberIdAndParticipantTypeNot(Long memberId, Participant Optional findByRoomIdAndMemberId( @Param("roomId") Long roomId, @Param("memberId") Long memberId ); + @Query(""" + SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END + FROM Participant p + WHERE p.room.id = :roomId + AND p.member.id = :memberId + AND (p.participantType = com.oronaminc.join.participant.domain.ParticipantType.PRESENTER + OR p.participantType = com.oronaminc.join.participant.domain.ParticipantType.TEAM) + """) + boolean existsPresenterOrTeamByMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); + void deleteByRoomId(Long roomId); } \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java b/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java index 40d5103..ec354ce 100644 --- a/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java +++ b/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java @@ -58,6 +58,10 @@ public Participant getByRoomIdAndMemberId(Long roomId, Long memberId) { .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT)); } + public boolean existsPresenterOrTeamByMemberId(Long roomId, Long memberId) { + return participantRepository.existsPresenterOrTeamByMemberId(roomId, memberId); + } + public void deleteByRoomId(Long roomId) { participantRepository.deleteByRoomId(roomId); } diff --git a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java index 593fe59..8d32fb6 100644 --- a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java +++ b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java @@ -111,5 +111,7 @@ where q.room.id in (:roomIds) List findByRoomId(Long roomId); + void deleteById(Long questionId); + void deleteByRoomId(Long roomId); } diff --git a/src/main/java/com/oronaminc/join/question/domain/Question.java b/src/main/java/com/oronaminc/join/question/domain/Question.java index e3eea51..dc67139 100644 --- a/src/main/java/com/oronaminc/join/question/domain/Question.java +++ b/src/main/java/com/oronaminc/join/question/domain/Question.java @@ -59,6 +59,10 @@ public static Question create(Room room, Member member, QuestionCreateRequest re .build(); } + public void updateContent(String content) { + this.content = content; + } + public Long incrementEmojiCount() { return ++this.emojiCount; } diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java new file mode 100644 index 0000000..a140a2a --- /dev/null +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.question.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema(description = "질문 삭제 응답 DTO") +public record QuestionDeleteResponse( + String event, + Long questionId +) { +} diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java new file mode 100644 index 0000000..efd0396 --- /dev/null +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java @@ -0,0 +1,14 @@ +package com.oronaminc.join.question.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "질문 수정 응답 DTO") +public record QuestionUpdateResponse( + String event, + Long questionId, + String content + + ) { +} diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionService.java b/src/main/java/com/oronaminc/join/question/service/QuestionService.java index 2646e05..843f2e1 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionService.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionService.java @@ -1,5 +1,8 @@ package com.oronaminc.join.question.service; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.participant.service.ParticipantReader; import java.util.List; import org.springframework.data.domain.PageRequest; @@ -36,6 +39,7 @@ public class QuestionService { private final QuestionReader questionReader; private final MemberReader memberReader; private final RoomReader roomReader; + private final ParticipantReader participantReader; @Transactional public Question create(Long roomId, Long memberId, QuestionCreateRequest requestDto) { @@ -81,6 +85,46 @@ public Slice getQuestions( return SliceUtil.toSlice(assembledList, PageRequest.of(0, size)); } + @Transactional + public Question update(Long memberId, Long roomId, Long questionId, QuestionCreateRequest request) { + Question question = questionReader.getByIdAndRoomId(questionId, roomId); + + // 참여자가 아님 + if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { + throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + } + + // 작성자가 아님 + if (!question.getMember().getId().equals(memberId)) { + throw new ErrorException(ErrorCode.UNAUTHORIZED_EDIT_QUESTION); + } + + question.updateContent(request.content()); + + return question; + } + + @Transactional + public Long delete(Long memberId, Long roomId, Long questionId) { + Question question = questionReader.getByIdAndRoomId(questionId, roomId); + + // 참여자가 아님 + if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { + throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + } + + // 관리자가 아님 && 작성자도 아님 + if (!participantReader.existsPresenterOrTeamByMemberId(roomId, memberId) + && !question.getMember().getId().equals(memberId)) { + throw new ErrorException(ErrorCode.UNAUTHORIZED_DELETE_QUESTION); + } + + answerService.deleteByQuestion(questionId); + questionRepository.deleteById(questionId); + + return question.getId(); + } + @Transactional public void deleteByRoomId(Long roomId) { diff --git a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java index a32ec86..2bbb9aa 100644 --- a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java +++ b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java @@ -6,9 +6,11 @@ import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.dto.QuestionCreateRequest; import com.oronaminc.join.question.dto.QuestionCreateResponse; +import com.oronaminc.join.question.dto.QuestionDeleteResponse; import com.oronaminc.join.question.dto.QuestionFlatResponse; import com.oronaminc.join.question.dto.QuestionAssembleResponse; import com.oronaminc.join.question.dto.QuestionListResponse; +import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.room.domain.Room; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -52,6 +54,21 @@ public static QuestionAssembleResponse toQuestionListResponse(QuestionFlatRespon .build(); } + public static QuestionUpdateResponse toQuestionUpdateResponse(Question question) { + return QuestionUpdateResponse.builder() + .event("UPDATE") + .questionId(question.getId()) + .content(question.getContent()) + .build(); + } + + public static QuestionDeleteResponse toQuestionDeleteResponse(Long questionId) { + return new QuestionDeleteResponse( + "DELETE", + questionId + ); + } + public static QuestionListResponse toQuestionListResponse( Slice slice) { return new QuestionListResponse(slice.getContent()); diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 0a22ca1..a947a70 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -5,6 +5,8 @@ import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.dto.QuestionCreateRequest; import com.oronaminc.join.question.dto.QuestionCreateResponse; +import com.oronaminc.join.question.dto.QuestionDeleteResponse; +import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.question.util.QuestionMapper; import com.oronaminc.join.question.service.QuestionService; import java.security.Principal; @@ -42,5 +44,34 @@ public QuestionCreateResponse create( return QuestionMapper.toQuestionCreateResponse(question); } + @MessageMapping("/rooms/{roomId}/questions/{questionId}/update") + @SendTo("/topic/rooms/{roomId}/questions") + public QuestionUpdateResponse update( + @DestinationVariable Long roomId, + @DestinationVariable Long questionId, + @Payload QuestionCreateRequest request, + Principal principal + ) { + + Long memberId = Long.valueOf(principal.getName()); + + Question updated = questionService.update(memberId, roomId, questionId, request); + + return QuestionMapper.toQuestionUpdateResponse(updated); + } + @MessageMapping("rooms/{roomId}/questions/{questionId}/delete") + @SendTo("/topic/rooms/{roomId}/questions") + public QuestionDeleteResponse delete( + @DestinationVariable Long roomId, + @DestinationVariable Long questionId, + Principal principal + ) { + + Long memberId = Long.valueOf(principal.getName()); + + Long deletedId = questionService.delete(memberId, roomId, questionId); + + return QuestionMapper.toQuestionDeleteResponse(deletedId); + } } diff --git a/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java b/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java index 29be952..802e6f7 100644 --- a/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java +++ b/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java @@ -34,15 +34,17 @@ class ParticipantRepositoryTests { private RoomRepository roomRepository; private Member member; + private Room room1; + private Room room3; @BeforeEach void setUp() { member = memberRepository.save(Member.builder().build()); Member otherMember = memberRepository.save(Member.builder().build()); - Room room1 = roomRepository.save(Room.builder().build()); + room1 = roomRepository.save(Room.builder().build()); Room room2 = roomRepository.save(Room.builder().build()); - Room room3 = roomRepository.save(Room.builder().build()); + room3 = roomRepository.save(Room.builder().build()); participantRepository.save(Participant.builder() .room(room1) @@ -70,6 +72,34 @@ void setUp() { ); } + @Test + @DisplayName("member가 PRESENTER나 TEAM이면 true가 반환된다.") + void existsPresenterOrTeamByMemberId_true() { + // given + + // when + boolean result = participantRepository.existsPresenterOrTeamByMemberId(room1.getId(), + member.getId()); + + // then + assertThat(result).isTrue(); + + } + + @Test + @DisplayName("member가 PRESENTER나 TEAM이 아니면 false가 반환된다.") + void existsPresenterOrTeamByMemberId_false() { + // given + + // when + boolean result = participantRepository.existsPresenterOrTeamByMemberId(room3.getId(), + member.getId()); + + // then + assertThat(result).isFalse(); + + } + @Test @DisplayName("멤버ID로 생성한방, 참여한방 수를 조회한다.") void countByMemberIdGroupByParticipantType_test() { diff --git a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java index 82ebce2..aeb38f4 100644 --- a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java +++ b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java @@ -1,30 +1,21 @@ package com.oronaminc.join.question.service; -import static org.assertj.core.api.AssertionsForClassTypes.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; - -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; -import org.springframework.test.context.ActiveProfiles; - +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.doNothing; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.willThrow; + +import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.participant.domain.Participant; -import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.participant.service.ParticipantReader; import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.dao.QuestionRepository; import com.oronaminc.join.question.domain.Question; @@ -35,8 +26,19 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; - +import java.time.LocalDateTime; +import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; @Slf4j @ActiveProfiles("test") @@ -53,13 +55,16 @@ class QuestionServiceTests { @Mock private MemberReader memberReader; @Mock + private ParticipantReader participantReader; + @Mock private ParticipantService participantService; @Mock private QuestionReader questionReader; + @Mock + private AnswerService answerService; private Room mockRoom; private Member mockMember; - private Participant mockParticipant; private QuestionCreateRequest request; private QuestionFlatResponse mockQ1; private QuestionFlatResponse mockQ2; @@ -87,13 +92,6 @@ void setUp() { .roomStatus(RoomStatus.STARTED) .build(); - mockParticipant = Participant.builder() - .id(1L) - .room(mockRoom) - .member(mockMember) - .participantType(ParticipantType.GUEST) - .build(); - request = new QuestionCreateRequest("질문입니다"); mockQ1 = QuestionFlatResponse.builder() @@ -119,6 +117,86 @@ void setUp() { } + @Test + @DisplayName("질문 작성자이거나 관리자이면 질문이 성공적으로 삭제된다") + void delete_sucess() { + // given + Long roomId = 1L; + Long memberId = 1L; + + Question question = Question.builder().id(1L).room(mockRoom).member(mockMember) + .content("질문").build(); + + given(participantReader.existsByRoomIdAndMemberId(roomId, memberId)).willReturn(true); + given(questionReader.getByIdAndRoomId(1L, roomId)).willReturn(question); + given(participantReader.existsPresenterOrTeamByMemberId(roomId, memberId)).willReturn(true); + doNothing().when(questionRepository).deleteById(1L); + doNothing().when(answerService).deleteByQuestion(1L); + + Long deleted = questionService.delete(memberId, roomId, 1L); + + assertThat(deleted).isEqualTo(1L); + verify(questionRepository).deleteById(1L); + } + + @Test + @DisplayName("질문이 성공적으로 수정된다") + void updateQuestion_success() { + // given + Long roomId = 1L; + Long memberId = 1L; + + Question question = Question.builder().id(1L).room(mockRoom).member(mockMember) + .content("변경 전").build(); + + given(participantReader.existsByRoomIdAndMemberId(roomId, memberId)).willReturn(true); + given(questionReader.getByIdAndRoomId(1L, roomId)).willReturn(question); + + // when + Question updated = questionService.update(memberId, roomId, 1L, request); + + // then + assertThat(updated.getId()).isEqualTo(1L); + assertThat(updated.getContent()).isEqualTo("질문입니다"); + + } + + @Test + @DisplayName("잘못된 값이 들어오면 질문 수정이 실패한다") + void updateQuestion_found_fail() { + // given + Long notRoomId = 999L; + + given(questionReader.getByIdAndRoomId(1L, notRoomId)).willThrow( + new ErrorException(ErrorCode.NOT_FOUND_ROOM_QUESTION)); + + // when & then + assertThatThrownBy(() -> questionService.update(999L, notRoomId, 1L, request)) + .isInstanceOf(ErrorException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND_ROOM_QUESTION); + + } + + @Test + @DisplayName("작성자가 아니면 질문 수정이 실패한다") + void updateQuestion_fail() { + // given + Long roomId = 1L; + Long notMemberId = 999L; + + Question question = Question.builder().id(1L).room(mockRoom).member(mockMember) + .content("변경 전").build(); + + given(participantReader.existsByRoomIdAndMemberId(roomId, notMemberId)).willReturn(true); + given(questionReader.getByIdAndRoomId(1L, roomId)).willReturn(question); + + // when then + assertThatThrownBy(() -> questionService.update(notMemberId, roomId, 1L, request)) + .isInstanceOf(ErrorException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.UNAUTHORIZED_EDIT_QUESTION); + + } + @Test @DisplayName("최신순 질문 목록 조회") void getQuestionByCreatedAt_success() { @@ -127,7 +205,6 @@ void getQuestionByCreatedAt_success() { Long memberId = 1L; int size = 1; - List mockList = List.of(mockQ1, mockQ2); given(memberReader.getById(memberId)).willReturn(mockMember); @@ -135,8 +212,8 @@ void getQuestionByCreatedAt_success() { given(questionReader.findByCreatedAt(null, memberId, roomId, PageRequest.of(0, size + 1))) .willReturn(mockList); - - Slice result = questionService.getQuestions(QuestionSort.CREATEDAT, + Slice result = questionService.getQuestions( + QuestionSort.CREATEDAT, null, null, size, memberId, roomId); assertThat(result).isNotNull(); @@ -155,10 +232,10 @@ void getQuestionByEmoji_success() { given(memberReader.getById(memberId)).willReturn(mockMember); given(roomReader.getById(roomId)).willReturn(mockRoom); - given(questionReader.findByEmojiCount(null, null, memberId, roomId, PageRequest.of(0, size + 1))) + given(questionReader.findByEmojiCount(null, null, memberId, roomId, + PageRequest.of(0, size + 1))) .willReturn(mockList); - Slice result = questionService.getQuestions(QuestionSort.EMOJI, null, null, size, memberId, roomId); @@ -181,8 +258,8 @@ void getQuestionByMyQuestion_success() { given(questionReader.findByMyQuestion(null, memberId, roomId, PageRequest.of(0, size + 1))) .willReturn(mockList); - - Slice result = questionService.getQuestions(QuestionSort.MYQUESTION, + Slice result = questionService.getQuestions( + QuestionSort.MYQUESTION, null, null, size, memberId, roomId); assertThat(result).isNotNull(); @@ -223,7 +300,8 @@ void createQuestion_success() { @DisplayName("존재하지 않는 member가 들어오면 예외 발생") void createQuestion_member_fail() { // given - given(memberReader.getById(anyLong())).willThrow(new ErrorException(ErrorCode.NOT_FOUND_MEMBER)); + given(memberReader.getById(anyLong())).willThrow( + new ErrorException(ErrorCode.NOT_FOUND_MEMBER)); // when & then assertThatThrownBy(() -> questionService.create(1L, 1L, request)) @@ -237,7 +315,8 @@ void createQuestion_member_fail() { void createQuestion_room_fail() { // given given(memberReader.getById(anyLong())).willReturn(mockMember); - given(roomReader.getById(anyLong())).willThrow(new ErrorException(ErrorCode.NOT_FOUND_ROOM)); + given(roomReader.getById(anyLong())).willThrow( + new ErrorException(ErrorCode.NOT_FOUND_ROOM)); // when & then assertThatThrownBy(() -> questionService.create(1L, 1L, request)) @@ -256,9 +335,8 @@ void createQuestion_participant_fail() { given(memberReader.getById(memberId)).willReturn(mockMember); given(roomReader.getById(roomId)).willReturn(mockRoom); willThrow(new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT)) - .given(participantService) - .validateParticipant(roomId, memberId); - + .given(participantService) + .validateParticipant(roomId, memberId); // when & then assertThatThrownBy(() -> questionService.create(1L, 1L, request)) From 9e16cd131b3a338f431d204410fb4cd189ce00e2 Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:12:58 +0900 Subject: [PATCH 02/74] =?UTF-8?q?feat:=20=EA=B2=B0=EA=B3=BC=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 결과 리포트 조회 기능 구현 * chore: 불필요한 쿼리문 제거 --- .../join/answer/dao/AnswerRepository.java | 11 ++++- .../join/answer/service/AnswerReader.java | 4 ++ .../join/global/exception/ErrorCode.java | 1 + .../dao/ParticipantRepository.java | 9 ++++ .../service/ParticipantReader.java | 4 ++ .../join/question/dao/QuestionRepository.java | 17 ++++++++ .../join/question/service/QuestionReader.java | 8 ++++ .../join/room/api/RoomController.java | 18 ++++---- .../join/room/dto/ReportResponse.java | 17 ++++++++ .../oronaminc/join/room/dto/TopQnADto.java | 8 ++++ .../join/room/service/RoomService.java | 42 +++++++++++++++---- .../oronaminc/join/room/util/RoomMapper.java | 17 ++++++-- 12 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/room/dto/ReportResponse.java create mode 100644 src/main/java/com/oronaminc/join/room/dto/TopQnADto.java diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index bf40e14..5bdc650 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -1,11 +1,12 @@ package com.oronaminc.join.answer.dao; import java.util.List; - import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.question.domain.Question; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface AnswerRepository extends JpaRepository { @@ -16,4 +17,12 @@ public interface AnswerRepository extends JpaRepository { void deleteByQuestionId(Long questionId); void deleteByQuestionIn(List questions); + + @Query(""" + select count(a) + from Answer a + where a.question.room.id = :roomId + """) + Long countAnsweredQuestionsByRoomId(@Param("roomId") Long roomId); + } diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java index c905c6d..37b487b 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java @@ -37,4 +37,8 @@ public Answer getById(Long answerId) { return findById(answerId) .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_ANSWER)); } + + public Long countAnsweredQuestionsByRoomId(Long roomId) { + return answerRepository.countAnsweredQuestionsByRoomId(roomId); + } } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 51d7a33..ede3fb2 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -24,6 +24,7 @@ public enum ErrorCode { NOT_FOUND_PARTICIPANT("PARTICIPANT-001", "발표방에 존재하지 않는 회원입니다.", NOT_FOUND), UNAUTHORIZED_TEAM_GUEST("PARTICIPANT-002", "게스트는 팀이 될 수 없습니다.", UNAUTHORIZED), UNAUTHORIZED_UPDATE_AND_DELETE("PARTICIPANT-003", "발표방 수정 및 삭제 권한이 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_REPORT_READ("PARTICIPANT-004","결과 리포트 조회 권한이 없습니다.", UNAUTHORIZED), FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java index 08de9b6..2b86ccc 100644 --- a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java +++ b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java @@ -72,4 +72,13 @@ SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END boolean existsPresenterOrTeamByMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); void deleteByRoomId(Long roomId); + + @Query(value = """ + SELECT COUNT(*) + FROM participant + WHERE room_id = :roomId + AND exited_at IS NOT NULL + AND TIMESTAMPDIFF(SECOND, created_at, exited_at) >= 30 + """, nativeQuery = true) + Long countParticipantsStayedOver30Seconds(@Param("roomId") Long roomId); } \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java b/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java index ec354ce..c472303 100644 --- a/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java +++ b/src/main/java/com/oronaminc/join/participant/service/ParticipantReader.java @@ -66,4 +66,8 @@ public void deleteByRoomId(Long roomId) { participantRepository.deleteByRoomId(roomId); } + public Long countTotalView(Long roomId) { + return participantRepository.countParticipantsStayedOver30Seconds(roomId); + } + } diff --git a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java index 8d32fb6..1631191 100644 --- a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java +++ b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java @@ -4,6 +4,8 @@ import java.util.Optional; import com.oronaminc.join.question.dto.QuestionFlatResponse; import java.util.List; + +import com.oronaminc.join.room.dto.TopQnADto; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -114,4 +116,19 @@ where q.room.id in (:roomIds) void deleteById(Long questionId); void deleteByRoomId(Long roomId); + + Long countByRoomId(@Param("roomId") Long roomId); + + @Query (""" + select new com.oronaminc.join.room.dto.TopQnADto( + q.content, + q.emojiCount, + a.content + ) + from Question q + left join Answer a on a.question.id = q.id + where q.room.id = :roomId + order by q.emojiCount desc + """) + List findTop3QnAByRoomId(Long roomId, Pageable pageable); } diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java index 7591439..7e560b9 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java @@ -3,6 +3,8 @@ import java.util.List; import java.util.Optional; +import com.oronaminc.join.room.dto.TopQnADto; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -60,4 +62,10 @@ public List countByRoomIds(List roomIds) { public boolean existsInRoom(Long roomId) { return !questionRepository.findByRoomId(roomId).isEmpty(); } + + public Long countByRoomId(Long roomId) { return questionRepository.countByRoomId(roomId);} + + public List findTop3QnA(Long roomId) { + return questionRepository.findTop3QnAByRoomId(roomId, PageRequest.of(0,3)); + } } diff --git a/src/main/java/com/oronaminc/join/room/api/RoomController.java b/src/main/java/com/oronaminc/join/room/api/RoomController.java index 6fbf1d1..61e4b02 100644 --- a/src/main/java/com/oronaminc/join/room/api/RoomController.java +++ b/src/main/java/com/oronaminc/join/room/api/RoomController.java @@ -1,5 +1,6 @@ package com.oronaminc.join.room.api; +import com.oronaminc.join.room.dto.*; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -13,14 +14,6 @@ import org.springframework.web.bind.annotation.RestController; import com.oronaminc.join.member.security.MemberDetails; -import com.oronaminc.join.room.dto.CreateRoomRequest; -import com.oronaminc.join.room.dto.CreateRoomResponse; -import com.oronaminc.join.room.dto.JoinRoomRequest; -import com.oronaminc.join.room.dto.JoinRoomResponse; -import com.oronaminc.join.room.dto.RoomDetailResponse; -import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; -import com.oronaminc.join.room.dto.RoomUpdateRequest; -import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; import com.oronaminc.join.room.service.RoomService; import io.swagger.v3.oas.annotations.Operation; @@ -103,4 +96,13 @@ public RoomUpdateInfoResponse getUpdateInfo( ) { return roomService.getRoomUpdateInfo(memberDetails.getId(), roomId); } + + @GetMapping("/{roomId}/report") + @ResponseStatus(HttpStatus.OK) + public ReportResponse getReport( + @PathVariable Long roomId, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + return roomService.getRoomReport(roomId, memberDetails.getId()); + } } diff --git a/src/main/java/com/oronaminc/join/room/dto/ReportResponse.java b/src/main/java/com/oronaminc/join/room/dto/ReportResponse.java new file mode 100644 index 0000000..d14d18e --- /dev/null +++ b/src/main/java/com/oronaminc/join/room/dto/ReportResponse.java @@ -0,0 +1,17 @@ +package com.oronaminc.join.room.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record ReportResponse( + Long roomId, + String title, + Long totalView, + Long totalQuestions, + Double answerRate, + Long totalEmojis, + List topQnA +) { +} diff --git a/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java b/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java new file mode 100644 index 0000000..ffcdc83 --- /dev/null +++ b/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java @@ -0,0 +1,8 @@ +package com.oronaminc.join.room.dto; + +public record TopQnADto( + String question, + Long emojiCount, + String answers +) { +} diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 4214a4e..4ab11d2 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -1,7 +1,13 @@ package com.oronaminc.join.room.service; import java.util.List; + +import com.oronaminc.join.answer.service.AnswerReader; +import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.infra.service.S3Service; +import com.oronaminc.join.participant.service.ParticipantReader; +import com.oronaminc.join.question.service.QuestionReader; +import com.oronaminc.join.room.dto.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,14 +25,6 @@ import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.dto.CreateRoomRequest; -import com.oronaminc.join.room.dto.CreateRoomResponse; -import com.oronaminc.join.room.dto.JoinRoomRequest; -import com.oronaminc.join.room.dto.JoinRoomResponse; -import com.oronaminc.join.room.dto.RoomDetailResponse; -import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; -import com.oronaminc.join.room.dto.RoomUpdateRequest; -import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; import com.oronaminc.join.room.util.CodeGenerator; import com.oronaminc.join.room.util.RoomMapper; @@ -45,9 +43,12 @@ public class RoomService { private final EmojiService emojiService; private final S3Service s3Service; private final RoomReader roomReader; + private final AnswerReader answerReader; + private final ParticipantReader participantReader; private static final int CODE_LENGTH = 6; + private final QuestionReader questionReader; public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, String presenterEmail) { @@ -144,4 +145,29 @@ private String generateCode() { } } } + + public ReportResponse getRoomReport(Long roomId, Long memberId) { + + Participant participant = participantService.getPresenter(roomId); + + if (!participant.getMember().getId().equals(memberId)) { + throw new ErrorException(UNAUTHORIZED_REPORT_READ); + } + + Room room = roomReader.getById(roomId); + Long totalView = participantReader.countTotalView(roomId); + Long totalQuestions = questionReader.countByRoomId(roomId); + Long totalAnswerByQuestion = answerReader.countAnsweredQuestionsByRoomId(roomId); + Double answerRate = calculateAnswerRate(totalQuestions, totalAnswerByQuestion); + List top3QnA = questionReader.findTop3QnA(roomId); + + return RoomMapper.toReportResponse(room, totalView,totalQuestions, answerRate, top3QnA); + } + + private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuestion) { + return (totalQuestions == 0) + ? 0.0 + : ((double) totalAnswerByQuestion / totalQuestions) * 100; + + } } diff --git a/src/main/java/com/oronaminc/join/room/util/RoomMapper.java b/src/main/java/com/oronaminc/join/room/util/RoomMapper.java index 46e89f1..d2df184 100644 --- a/src/main/java/com/oronaminc/join/room/util/RoomMapper.java +++ b/src/main/java/com/oronaminc/join/room/util/RoomMapper.java @@ -8,10 +8,7 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.domain.RoomType; -import com.oronaminc.join.room.dto.CreateRoomRequest; -import com.oronaminc.join.room.dto.CreateRoomResponse; -import com.oronaminc.join.room.dto.RoomDetailResponse; -import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; +import com.oronaminc.join.room.dto.*; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -66,4 +63,16 @@ public static RoomUpdateInfoResponse toRoomUpdateInfoResponse(Room room, List teamParticipant.getMember().getEmail()).toList()) .build(); } + + public static ReportResponse toReportResponse(Room room, Long totalView,Long totalQuestions, Double answerRate, List top3QnA) { + return ReportResponse.builder() + .roomId(room.getId()) + .title(room.getTitle()) + .totalView(totalView) + .totalQuestions(totalQuestions) + .answerRate(answerRate) + .totalEmojis(room.getEmojiCount()) + .topQnA(top3QnA) + .build(); + } } From ceb2f9cc68e7b4b03487289412a2a4901d295fae Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:32:09 +0900 Subject: [PATCH 03/74] =?UTF-8?q?refactor:=20Top3QnA=20=EB=8B=B5=EB=B3=80?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/answer/dao/AnswerRepository.java | 9 ++++- .../join/answer/service/AnswerReader.java | 4 ++ .../join/question/dao/QuestionRepository.java | 9 +---- .../join/question/service/QuestionReader.java | 4 +- .../oronaminc/join/room/dto/TopQnADto.java | 4 +- .../join/room/service/RoomService.java | 38 +++++++++++++++++-- 6 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index 5bdc650..265fb45 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -19,10 +19,17 @@ public interface AnswerRepository extends JpaRepository { void deleteByQuestionIn(List questions); @Query(""" - select count(a) + select count(distinct a.question.id) from Answer a where a.question.room.id = :roomId """) Long countAnsweredQuestionsByRoomId(@Param("roomId") Long roomId); + @Query(""" + select a + from Answer a + where a.question.id in :questionIds + """) + List findAllByQuestionIds(@Param("questionIds") List questionIds); + } diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java index 37b487b..1935242 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java @@ -41,4 +41,8 @@ public Answer getById(Long answerId) { public Long countAnsweredQuestionsByRoomId(Long roomId) { return answerRepository.countAnsweredQuestionsByRoomId(roomId); } + + public List getAnswerByQuestionIds(List questionIds) { + return answerRepository.findAllByQuestionIds(questionIds); + } } diff --git a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java index 1631191..af8af3f 100644 --- a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java +++ b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java @@ -120,15 +120,10 @@ where q.room.id in (:roomIds) Long countByRoomId(@Param("roomId") Long roomId); @Query (""" - select new com.oronaminc.join.room.dto.TopQnADto( - q.content, - q.emojiCount, - a.content - ) + select q from Question q - left join Answer a on a.question.id = q.id where q.room.id = :roomId order by q.emojiCount desc """) - List findTop3QnAByRoomId(Long roomId, Pageable pageable); + List findTop3QuestionByRoomId(Long roomId, Pageable pageable); } diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java index 7e560b9..0b0acb1 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java @@ -65,7 +65,7 @@ public boolean existsInRoom(Long roomId) { public Long countByRoomId(Long roomId) { return questionRepository.countByRoomId(roomId);} - public List findTop3QnA(Long roomId) { - return questionRepository.findTop3QnAByRoomId(roomId, PageRequest.of(0,3)); + public List findTop3Question(Long roomId) { + return questionRepository.findTop3QuestionByRoomId(roomId, PageRequest.of(0,3)); } } diff --git a/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java b/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java index ffcdc83..7e76bc3 100644 --- a/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java +++ b/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java @@ -1,8 +1,10 @@ package com.oronaminc.join.room.dto; +import java.util.List; + public record TopQnADto( String question, Long emojiCount, - String answers + List answers ) { } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 4ab11d2..69af3f1 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -1,11 +1,14 @@ package com.oronaminc.join.room.service; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; -import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.infra.service.S3Service; import com.oronaminc.join.participant.service.ParticipantReader; +import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.dto.*; import org.springframework.stereotype.Service; @@ -159,9 +162,38 @@ public ReportResponse getRoomReport(Long roomId, Long memberId) { Long totalQuestions = questionReader.countByRoomId(roomId); Long totalAnswerByQuestion = answerReader.countAnsweredQuestionsByRoomId(roomId); Double answerRate = calculateAnswerRate(totalQuestions, totalAnswerByQuestion); - List top3QnA = questionReader.findTop3QnA(roomId); + List topQnA = getTopQnA(roomId); - return RoomMapper.toReportResponse(room, totalView,totalQuestions, answerRate, top3QnA); + + return RoomMapper.toReportResponse(room, totalView,totalQuestions, answerRate, topQnA); + } + + private List getTopQnA(Long roomId) { + // top3 질문 리스트 + List top3Question = questionReader.findTop3Question(roomId); + + // top3 질문 id + List questionIds = top3Question.stream() + .map(Question::getId) + .toList(); + + // top3에 질문의 답변 조회 + List answerByQuestionIds = answerReader.getAnswerByQuestionIds(questionIds); + + // 답변을 질문 ID 기준으로 그룹화 + Map> answersByQuestionId = answerByQuestionIds.stream() + .collect(Collectors.groupingBy( + a -> a.getQuestion().getId(), + Collectors.mapping(Answer::getContent, Collectors.toList()) + )); + + return top3Question.stream() + .map( q -> new TopQnADto( + q.getContent(), + q.getEmojiCount(), + answersByQuestionId.getOrDefault(q.getId(), List.of()) + )) + .toList(); } private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuestion) { From db10dbae0658854b27f8cf0a7b893081e3c9a21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=EA=B2=BD=EC=A4=80?= Date: Wed, 16 Jul 2025 10:25:30 +0900 Subject: [PATCH 04/74] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=90=20API=20Rate?= =?UTF-8?q?=20Limiting=20=EC=A0=81=EC=9A=A9=20/=20=EA=B3=B5=EA=B0=90=20API?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 공감 api 분리 * feat: 공감 api rate limit 적용 * refactor: 이모지 요청 dto validation 적용 * fix: 공감 생성, 삭제 예외처리 추가 * style: 버킷 메서드명 변경 * style: 버킷 메서드명 변경 --- build.gradle | 2 + .../join/emoji/dao/EmojiRepository.java | 9 +- .../join/emoji/dto/EmojiRequest.java | 3 + .../join/emoji/service/EmojiFacade.java | 23 ++- .../join/emoji/service/EmojiReader.java | 29 +++- .../join/emoji/service/EmojiService.java | 49 +++--- .../join/global/exception/ErrorCode.java | 8 +- .../join/global/exception/ErrorStatus.java | 3 +- .../global/exception/ExceptionAdvice.java | 9 +- .../global/ratelimit/RateLimitService.java | 26 ++++ .../join/global/ratelimit/RateLimitType.java | 31 ++++ .../join/global/util/StringUtil.java | 13 ++ .../api/EmojiWebsocketController.java | 38 ++++- .../join/emoji/service/EmojiFacadeTests.java | 77 +++++++++- .../join/emoji/service/EmojiServiceTests.java | 139 +++++++++++++----- 15 files changed, 375 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/ratelimit/RateLimitService.java create mode 100644 src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java create mode 100644 src/main/java/com/oronaminc/join/global/util/StringUtil.java diff --git a/build.gradle b/build.gradle index e4bdf23..cb4e4e0 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,8 @@ dependencies { // S3 implementation 'software.amazon.awssdk:s3:2.31.77' + // bucket4j + implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java index dbf2af5..a4c62e9 100644 --- a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java +++ b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java @@ -1,11 +1,9 @@ package com.oronaminc.join.emoji.dao; -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; - import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface EmojiRepository extends JpaRepository { @@ -15,4 +13,7 @@ Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targe TargetType targetType); Integer countByTargetIdAndTargetType(Long targetId, TargetType targetType); + + boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, + TargetType targetType); } diff --git a/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java b/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java index f30ff76..1b8b688 100644 --- a/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java +++ b/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java @@ -2,11 +2,14 @@ import com.oronaminc.join.emoji.domain.TargetType; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; @Schema(description = "발표방/질문/답변 공감 생성/삭제 요청 DTO") public record EmojiRequest( + @NotNull @Schema(description = "공감 대상 타입 (ROOM, QUESTION, ANSWER)", example = "ROOM") TargetType targetType, + @NotNull @Schema(description = "공감 대상 ID", example = "1") Long targetId ) { diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java index 5b55fe2..bba1635 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java @@ -14,19 +14,34 @@ public class EmojiFacade { private final EmojiService emojiService; - public EmojiResponse toggleEmoji(Long memberId, EmojiRequest emojiRequest) { + public EmojiResponse createEmoji(Long memberId, EmojiRequest emojiRequest) { for (int i = 0; i < 10; i++) { try { - return emojiService.toggleEmoji(memberId, emojiRequest); + return emojiService.createEmoji(memberId, emojiRequest); } catch (ObjectOptimisticLockingFailureException e) { try { Thread.sleep(50); } catch (InterruptedException ex) { - throw new ErrorException(ErrorCode.EMOJI_CONFLICT); + throw new ErrorException(ErrorCode.CONFLICT_EMOJI); } } } - throw new ErrorException(ErrorCode.EMOJI_CONFLICT); + throw new ErrorException(ErrorCode.CONFLICT_EMOJI); + } + + public EmojiResponse deleteEmoji(Long memberId, EmojiRequest emojiRequest) { + for (int i = 0; i < 10; i++) { + try { + return emojiService.deleteEmoji(memberId, emojiRequest); + } catch (ObjectOptimisticLockingFailureException e) { + try { + Thread.sleep(50); + } catch (InterruptedException ex) { + throw new ErrorException(ErrorCode.CONFLICT_EMOJI); + } + } + } + throw new ErrorException(ErrorCode.CONFLICT_EMOJI); } } diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java index 7c5112a..99a3552 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java @@ -1,26 +1,41 @@ package com.oronaminc.join.emoji.service; -import java.util.Optional; - -import org.springframework.stereotype.Component; - import com.oronaminc.join.emoji.dao.EmojiRepository; import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; - +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class EmojiReader { + private final EmojiRepository emojiRepository; - public Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, TargetType targetType) { - return emojiRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType); + public Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, + TargetType targetType) { + return emojiRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType); + } + + public Emoji findEmojiByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, + TargetType targetType) { + return emojiRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType) + .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_EMOJI)); } public Integer countByTargetIdAndTargetType(Long targetId, TargetType targetType) { return emojiRepository.countByTargetIdAndTargetType(targetId, targetType); } + public boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, + TargetType targetType) { + return emojiRepository.existsByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType); + } + } diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java index 3f8b5cc..8bdc68b 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java @@ -1,21 +1,19 @@ package com.oronaminc.join.emoji.service; -import java.util.Optional; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.emoji.dao.EmojiRepository; import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.member.service.MemberReader; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.service.RoomReader; - import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -35,23 +33,16 @@ public void deleteByRoomEmoji(Long roomId) { } @Transactional - public EmojiResponse toggleEmoji(Long memberId, EmojiRequest emojiRequest) { + public EmojiResponse createEmoji(Long memberId, EmojiRequest emojiRequest) { Long emojiCount; TargetType targetType = emojiRequest.targetType(); Long targetId = emojiRequest.targetId(); - Optional findEmoji = emojiReader.findByMemberIdAndTargetIdAndTargetType( - memberId, targetId, targetType); - - if (findEmoji.isPresent()) { - emojiRepository.delete(findEmoji.get()); - emojiCount = decrementEmojiCount(targetType, targetId); - - return new EmojiResponse("DELETE", targetType, targetId, emojiCount); + if (emojiReader.existsByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType)) { + throw new ErrorException(ErrorCode.ALREADY_EXISTS_EMOJI); } - Emoji emoji = Emoji.create(memberReader.getById(memberId), targetType, targetId); - emojiRepository.save(emoji); + emojiRepository.save(Emoji.create(memberReader.getById(memberId), targetType, targetId)); emojiCount = incrementEmojiCount(targetType, targetId); @@ -59,6 +50,22 @@ public EmojiResponse toggleEmoji(Long memberId, EmojiRequest emojiRequest) { } + @Transactional + public EmojiResponse deleteEmoji(Long memberId, EmojiRequest emojiRequest) { + Long emojiCount; + TargetType targetType = emojiRequest.targetType(); + Long targetId = emojiRequest.targetId(); + + emojiRepository.delete( + emojiReader.findEmojiByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType) + ); + emojiCount = decrementEmojiCount(targetType, targetId); + + return new EmojiResponse("DELETE", targetType, targetId, emojiCount); + + } + private Long decrementEmojiCount(TargetType targetType, Long targetId) { return switch (targetType) { case ROOM -> roomReader.getById(targetId).decrementEmojiCount(); @@ -75,4 +82,12 @@ private Long incrementEmojiCount(TargetType targetType, Long targetId) { }; } + private Long getEmojiCount(TargetType targetType, Long targetId) { + return switch (targetType) { + case ROOM -> roomReader.getById(targetId).getEmojiCount(); + case QUESTION -> questionReader.getById(targetId).getEmojiCount(); + case ANSWER -> answerReader.getById(targetId).getEmojiCount(); + }; + } + } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index ede3fb2..4609c68 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -5,6 +5,7 @@ import static com.oronaminc.join.global.exception.ErrorStatus.FORBIDDEN; import static com.oronaminc.join.global.exception.ErrorStatus.INTERNAL_SERVER_ERROR; import static com.oronaminc.join.global.exception.ErrorStatus.NOT_FOUND; +import static com.oronaminc.join.global.exception.ErrorStatus.TOO_MANY_REQUESTS; import static com.oronaminc.join.global.exception.ErrorStatus.UNAUTHORIZED; import lombok.AllArgsConstructor; @@ -24,7 +25,7 @@ public enum ErrorCode { NOT_FOUND_PARTICIPANT("PARTICIPANT-001", "발표방에 존재하지 않는 회원입니다.", NOT_FOUND), UNAUTHORIZED_TEAM_GUEST("PARTICIPANT-002", "게스트는 팀이 될 수 없습니다.", UNAUTHORIZED), UNAUTHORIZED_UPDATE_AND_DELETE("PARTICIPANT-003", "발표방 수정 및 삭제 권한이 없습니다.", UNAUTHORIZED), - UNAUTHORIZED_REPORT_READ("PARTICIPANT-004","결과 리포트 조회 권한이 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_REPORT_READ("PARTICIPANT-004", "결과 리포트 조회 권한이 없습니다.", UNAUTHORIZED), FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), @@ -48,7 +49,10 @@ public enum ErrorCode { SOCKET_RUNTIME_ERROR("SOCKET-2000", "처리되지 않은 오류가 발생했습니다", INTERNAL_SERVER_ERROR), SOCKET_VALIDATION_ERROR("SOCKET-1001", "입력값이 유효하지 않습니다.", BAD_REQUEST), - EMOJI_CONFLICT("EMOJI-001", "공감 처리 중 충돌이 발생했습니다.", CONFLICT); + CONFLICT_EMOJI("EMOJI-001", "공감 처리 중 충돌이 발생했습니다.", CONFLICT), + NOT_FOUND_EMOJI("EMOJI-002", "해당 이모지가 존재하지 않습니다.", NOT_FOUND), + TOO_MANY_REQUESTS_EMOJI("EMOJI-003", "잠시 후 다시 시도해주세요.", TOO_MANY_REQUESTS), + ALREADY_EXISTS_EMOJI("EMOJI-004", "이미 해당 이모지가 존재합니다.", CONFLICT); private final String code; private final String message; diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorStatus.java b/src/main/java/com/oronaminc/join/global/exception/ErrorStatus.java index 75d0929..5fa20c5 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorStatus.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorStatus.java @@ -7,5 +7,6 @@ public enum ErrorStatus { ALREADY_EXISTS, BAD_REQUEST, UNAUTHORIZED, - FORBIDDEN + FORBIDDEN, + TOO_MANY_REQUESTS } diff --git a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java index ae89e68..c166075 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java +++ b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java @@ -1,5 +1,7 @@ package com.oronaminc.join.global.exception; +import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS; + import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -25,6 +27,7 @@ public ResponseEntity handleErrorException(ErrorException ex) { case ALREADY_EXISTS, BAD_REQUEST -> HttpStatus.BAD_REQUEST; case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; case FORBIDDEN -> HttpStatus.FORBIDDEN; + case TOO_MANY_REQUESTS -> TOO_MANY_REQUESTS; }; return ResponseEntity.status(httpStatus) @@ -50,11 +53,13 @@ public ResponseEntity handleUnhandled(Exception ex) { } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex) { log.error("Method argument not valid", ex); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse("400", ex.getBindingResult().getAllErrors().get(0).getDefaultMessage())); + .body(new ErrorResponse("400", + ex.getBindingResult().getAllErrors().get(0).getDefaultMessage())); } } diff --git a/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitService.java b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitService.java new file mode 100644 index 0000000..5fc204c --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitService.java @@ -0,0 +1,26 @@ +package com.oronaminc.join.global.ratelimit; + +import io.github.bucket4j.Bucket; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RateLimitService { + + private final Map cache = new ConcurrentHashMap<>(); + + public Bucket getBucket(RateLimitType rateLimitType, Object... args) { + String apiKey = rateLimitType.createKey(args); + return cache.computeIfAbsent(apiKey, key -> + Bucket.builder() + .addLimit( + rateLimitType.getBandwidth() + ) + .build() + ); + } + +} diff --git a/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java new file mode 100644 index 0000000..4f860c7 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java @@ -0,0 +1,31 @@ +package com.oronaminc.join.global.ratelimit; + +import com.oronaminc.join.global.util.StringUtil; +import io.github.bucket4j.Bandwidth; +import java.time.Duration; +import lombok.Getter; + +public enum RateLimitType { + EMOJI( + "EMOJI:{}:{}:{}", + Bandwidth.builder() + .capacity(3) + .refillIntervally(3, Duration.ofSeconds(1)) + .build() + ); + + private final String format; + + @Getter + private final Bandwidth bandwidth; + + RateLimitType(String format, Bandwidth bandwidth) { + this.format = format; + this.bandwidth = bandwidth; + } + + public String createKey(Object... args) { + return StringUtil.format(format, args); + } + +} diff --git a/src/main/java/com/oronaminc/join/global/util/StringUtil.java b/src/main/java/com/oronaminc/join/global/util/StringUtil.java new file mode 100644 index 0000000..1285d81 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/util/StringUtil.java @@ -0,0 +1,13 @@ +package com.oronaminc.join.global.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.slf4j.helpers.MessageFormatter; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StringUtil { + + public static String format(String format, Object... args) { + return MessageFormatter.arrayFormat(format, args).getMessage(); + } +} \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java index eda7161..30e1762 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java @@ -3,6 +3,12 @@ import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; import com.oronaminc.join.emoji.service.EmojiFacade; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.global.ratelimit.RateLimitService; +import com.oronaminc.join.global.ratelimit.RateLimitType; +import io.github.bucket4j.Bucket; +import jakarta.validation.Valid; import java.security.Principal; import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.DestinationVariable; @@ -16,17 +22,43 @@ public class EmojiWebsocketController { private final EmojiFacade emojiFacade; + private final RateLimitService rateLimitService; - @MessageMapping("/rooms/{roomId}/emojis") + @MessageMapping("/rooms/{roomId}/emojis/create") @SendTo("/topic/rooms/{roomId}/emojis") - public EmojiResponse toggleEmoji( + public EmojiResponse createEmoji( + @DestinationVariable Long roomId, + @Payload @Valid EmojiRequest emojiRequest, + Principal principal + ) { + Long memberId = Long.valueOf(principal.getName()); + + Bucket bucket = rateLimitService.getBucket(RateLimitType.EMOJI, memberId, + emojiRequest.targetType(), emojiRequest.targetId()); + + if (!bucket.tryConsume(1)) { + throw new ErrorException(ErrorCode.TOO_MANY_REQUESTS_EMOJI); + } + + return emojiFacade.createEmoji(memberId, emojiRequest); + } + + @MessageMapping("/rooms/{roomId}/emojis/delete") + @SendTo("/topic/rooms/{roomId}/emojis") + public EmojiResponse deleteEmoji( @DestinationVariable Long roomId, @Payload EmojiRequest emojiRequest, Principal principal ) { Long memberId = Long.valueOf(principal.getName()); - return emojiFacade.toggleEmoji(memberId, emojiRequest); + Bucket bucket = rateLimitService.getBucket(RateLimitType.EMOJI, memberId, + emojiRequest.targetType(), emojiRequest.targetId()); + if (!bucket.tryConsume(1)) { + throw new ErrorException(ErrorCode.TOO_MANY_REQUESTS_EMOJI); + } + + return emojiFacade.deleteEmoji(memberId, emojiRequest); } } diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java index a7c316d..a872793 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import com.oronaminc.join.answer.service.AnswerReader; +import com.oronaminc.join.emoji.dao.EmojiRepository; +import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.member.dao.MemberRepository; @@ -40,14 +42,75 @@ class EmojiFacadeTests { @Autowired private MemberRepository memberRepository; + @Autowired + private EmojiRepository emojiRepository; + @Autowired private EmojiFacade emojiFacade; @Test - @DisplayName("동시에 50개의 공감 요청") + @DisplayName("동시에 50개의 공감 생성 요청") + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void createEmoji_success_test() throws InterruptedException { + + Long emojiCount = 0L; + // given + Room savedRoom = roomRepository.saveAndFlush( + Room.builder() + .title("제목") + .description("내용") + .secretCode("123456") + .emojiCount(emojiCount) + .participantLimit(0) + .endedAt(LocalDateTime.now()) + .version(0) + .roomStatus(RoomStatus.STARTED) + .build() + ); + Long roomId = savedRoom.getId(); + + int threadCount = 50; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + List members = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + Member member = Member.builder().build(); + members.add(memberRepository.saveAndFlush(member)); + } + + // when + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executorService.submit(() -> { + try { + emojiFacade.createEmoji(members.get(idx).getId(), + new EmojiRequest(TargetType.ROOM, roomId)); + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // then + Room findRoom = roomRepository.findById(roomId).orElse(null); + assertThat(findRoom.getEmojiCount()).isEqualTo(threadCount); + assertThat(findRoom.getVersion()).isEqualTo(threadCount); + + } + + @Test + @DisplayName("동시에 50개의 공감 삭제 요청") @Transactional(propagation = Propagation.NOT_SUPPORTED) - void toggleEmoji_success_test() throws InterruptedException { + void deleteEmoji_success_test() throws InterruptedException { + + Long emojiCount = 50L; // given Room savedRoom = roomRepository.saveAndFlush( @@ -55,7 +118,7 @@ void toggleEmoji_success_test() throws InterruptedException { .title("제목") .description("내용") .secretCode("123456") - .emojiCount(0L) + .emojiCount(emojiCount) .participantLimit(0) .endedAt(LocalDateTime.now()) .version(0) @@ -72,7 +135,7 @@ void toggleEmoji_success_test() throws InterruptedException { for (int i = 0; i < threadCount; i++) { Member member = Member.builder().build(); members.add(memberRepository.saveAndFlush(member)); - System.out.println("memberId: " + members.get(i).getId()); + emojiRepository.saveAndFlush(Emoji.create(member, TargetType.ROOM, roomId)); } // when @@ -80,7 +143,7 @@ void toggleEmoji_success_test() throws InterruptedException { final int idx = i; executorService.submit(() -> { try { - emojiFacade.toggleEmoji(members.get(idx).getId(), + emojiFacade.deleteEmoji(members.get(idx).getId(), new EmojiRequest(TargetType.ROOM, roomId)); } catch (Exception e) { e.printStackTrace(); @@ -95,8 +158,8 @@ void toggleEmoji_success_test() throws InterruptedException { // then Room findRoom = roomRepository.findById(roomId).orElse(null); - assertThat(findRoom.getEmojiCount()).isEqualTo(50); - assertThat(findRoom.getVersion()).isEqualTo(50); + assertThat(findRoom.getEmojiCount()).isEqualTo(0); + assertThat(findRoom.getVersion()).isEqualTo(threadCount); } diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java index 176bfc8..bea6d9d 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java @@ -1,18 +1,9 @@ package com.oronaminc.join.emoji.service; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; @@ -21,12 +12,21 @@ import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.service.MemberReader; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class EmojiServiceTests { @@ -53,7 +53,7 @@ class EmojiServiceTests { private EmojiService emojiService; @Test - @DisplayName("멤버가 발표방 좋아요를 누르지 않은 상태에서 toggle 시 좋아요 수가 +1 된다") + @DisplayName("멤버가 발표방 좋아요를 누르지 않은 상태에서 요청 시 좋아요 수가 +1 된다") void toggleEmoji_createRoomEmoji_success() { // given Member member = Member.builder().build(); @@ -72,14 +72,14 @@ void toggleEmoji_createRoomEmoji_success() { .targetId(targetId) .build(); - when(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, - targetType)).thenReturn(Optional.empty()); + when(emojiReader.existsByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenReturn(false); when(memberReader.getById(memberId)).thenReturn(member); when(emojiRepository.save(any(Emoji.class))).thenReturn(findEmoji); when(roomReader.getById(targetId)).thenReturn(room); // when - EmojiResponse response = emojiService.toggleEmoji(memberId, + EmojiResponse response = emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId)); // then @@ -91,7 +91,7 @@ void toggleEmoji_createRoomEmoji_success() { } @Test - @DisplayName("멤버가 질문 공감을 누르지 않은 상태에서 toggle 시 공감 수가 +1 된다") + @DisplayName("멤버가 질문 공감을 누르지 않은 상태에서 요청 시 공감 수가 +1 된다") void toggleEmoji_createQuestionEmoji_success() { // given Member member = Member.builder().build(); @@ -110,14 +110,14 @@ void toggleEmoji_createQuestionEmoji_success() { .targetId(targetId) .build(); - when(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, - targetType)).thenReturn(Optional.empty()); + when(emojiReader.existsByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenReturn(false); when(memberReader.getById(memberId)).thenReturn(member); when(emojiRepository.save(any(Emoji.class))).thenReturn(findEmoji); when(questionReader.getById(targetId)).thenReturn(question); // when - EmojiResponse response = emojiService.toggleEmoji(memberId, + EmojiResponse response = emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId)); // then @@ -129,7 +129,7 @@ void toggleEmoji_createQuestionEmoji_success() { } @Test - @DisplayName("멤버가 답변 공감을 누르지 않은 상태에서 toggle 시 공감 수가 +1 된다") + @DisplayName("멤버가 답변 공감을 누르지 않은 상태에서 요청 시 공감 수가 +1 된다") void toggleEmoji_createAnswerEmoji_success() { // given Member member = Member.builder().build(); @@ -148,14 +148,14 @@ void toggleEmoji_createAnswerEmoji_success() { .targetId(targetId) .build(); - when(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, - targetType)).thenReturn(Optional.empty()); + when(emojiReader.existsByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenReturn(false); when(memberReader.getById(memberId)).thenReturn(member); when(emojiRepository.save(any(Emoji.class))).thenReturn(findEmoji); when(answerReader.getById(targetId)).thenReturn(answer); // when - EmojiResponse response = emojiService.toggleEmoji(memberId, + EmojiResponse response = emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId)); // then @@ -167,7 +167,40 @@ void toggleEmoji_createAnswerEmoji_success() { } @Test - @DisplayName("멤버가 발표방 좋아요를 누른 상태에서 toggle 시 좋아요 수가 -1 된다") + @DisplayName("멤버가 발표방 좋아요를 누른 상태에서 요청 시 좋아요 수가 그대로 반환된다") + void toggleEmoji_createRoomEmoji_fail() { + // given + Member member = Member.builder().build(); + Long memberId = member.getId(); + + TargetType targetType = TargetType.ROOM; + Long targetId = 100L; + Long emojiCount = 1L; + + Room room = Room.builder().emojiCount(emojiCount).build(); + ReflectionTestUtils.setField(room, "id", targetId); + + Emoji findEmoji = Emoji.builder() + .member(member) + .targetType(targetType) + .targetId(targetId) + .build(); + + when(emojiReader.existsByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenReturn(true); + + // when + // then + assertThatThrownBy( + () -> { + emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId)); + } + ).isInstanceOf(ErrorException.class); + + } + + @Test + @DisplayName("멤버가 발표방 좋아요를 누른 상태에서 요청 시 좋아요 수가 -1 된다") void toggleEmoji_deleteRoomEmoji_success() { // given Member member = Member.builder().build(); @@ -186,12 +219,12 @@ void toggleEmoji_deleteRoomEmoji_success() { .targetId(targetId) .build(); - when(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, - targetType)).thenReturn(Optional.of(findEmoji)); + when(emojiReader.findEmojiByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenReturn(findEmoji); when(roomReader.getById(targetId)).thenReturn(room); // when - EmojiResponse response = emojiService.toggleEmoji(memberId, + EmojiResponse response = emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId)); // then @@ -203,7 +236,7 @@ void toggleEmoji_deleteRoomEmoji_success() { } @Test - @DisplayName("멤버가 질문 공감을 누른 상태에서 toggle 시 공감 수가 -1 된다") + @DisplayName("멤버가 질문 공감을 누른 상태에서 요청 시 공감 수가 -1 된다") void toggleEmoji_deleteQuestionEmoji_success() { // given Member member = Member.builder().build(); @@ -222,12 +255,12 @@ void toggleEmoji_deleteQuestionEmoji_success() { .targetId(targetId) .build(); - when(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, - targetType)).thenReturn(Optional.of(findEmoji)); + when(emojiReader.findEmojiByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenReturn(findEmoji); when(questionReader.getById(targetId)).thenReturn(question); // when - EmojiResponse response = emojiService.toggleEmoji(memberId, + EmojiResponse response = emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId)); // then @@ -239,7 +272,7 @@ void toggleEmoji_deleteQuestionEmoji_success() { } @Test - @DisplayName("멤버가 발표방 좋아요를 누른 상태에서 toggle 시 좋아요 수가 -1 된다") + @DisplayName("멤버가 발표방 좋아요를 누른 상태에서 요청 시 좋아요 수가 -1 된다") void toggleEmoji_deleteAnswerEmoji_success() { // given Member member = Member.builder().build(); @@ -258,12 +291,12 @@ void toggleEmoji_deleteAnswerEmoji_success() { .targetId(targetId) .build(); - when(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, - targetType)).thenReturn(Optional.of(findEmoji)); + when(emojiReader.findEmojiByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenReturn(findEmoji); when(answerReader.getById(targetId)).thenReturn(answer); // when - EmojiResponse response = emojiService.toggleEmoji(memberId, + EmojiResponse response = emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId)); // then @@ -274,5 +307,37 @@ void toggleEmoji_deleteAnswerEmoji_success() { } + @Test + @DisplayName("멤버가 발표방 좋아요를 누르지 않은 상태에서 요청 시 좋아요 수가 그대로 반환된다") + void toggleEmoji_deleteRoomEmoji_fail() { + // given + Member member = Member.builder().build(); + Long memberId = member.getId(); + + TargetType targetType = TargetType.ROOM; + Long targetId = 100L; + Long emojiCount = 3L; + + Room room = Room.builder().emojiCount(emojiCount).build(); + ReflectionTestUtils.setField(room, "id", targetId); + + Emoji findEmoji = Emoji.builder() + .member(member) + .targetType(targetType) + .targetId(targetId) + .build(); + + when(emojiReader.findEmojiByMemberIdAndTargetIdAndTargetType(memberId, targetId, + targetType)).thenThrow(new ErrorException(ErrorCode.NOT_FOUND_EMOJI)); + + // when + // then + assertThatThrownBy( + () -> { + emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId)); + } + ).isInstanceOf(ErrorException.class); + + } } \ No newline at end of file From 2e24d58560458f42c4f4da36b387f3544881a2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:46:38 +0900 Subject: [PATCH 05/74] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=EB=B0=A9=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20=EB=B0=8F=20=EC=9E=85=EC=9E=A5=20=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방 참가 웹소켓 구현 * feat: 방 퇴장 웹소켓 구현 * feat: 방 구독 및 참여 조건 구현 * feat: 방 상세조회 인원 수정 * feat: 세션 관리, 방 퇴장 수정 --- .../join/global/exception/ErrorCode.java | 11 ++- .../join/room/domain/RoomStatus.java | 10 ++- .../join/room/dto/RoomExitEvent.java | 7 ++ .../join/room/dto/RoomJoinResponse.java | 6 ++ .../join/room/service/RoomService.java | 63 ++++++++++----- .../oronaminc/join/room/util/RoomMapper.java | 12 ++- .../api/RoomWebsocketController.java | 41 ++++++++++ .../api/WebSocketExceptionHandler.java | 16 ++-- .../CustomWebSocketHandlerDecorator.java | 36 +++++++-- .../config/ParticipantEventHandler.java | 80 +++++++++++++++++++ .../websocket/config/ParticipantManager.java | 42 ++++++++++ .../websocket/config/WebSocketConfig.java | 36 ++++++--- .../config/WebsocketSessionManager.java | 4 + 13 files changed, 315 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/room/dto/RoomExitEvent.java create mode 100644 src/main/java/com/oronaminc/join/room/dto/RoomJoinResponse.java create mode 100644 src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java create mode 100644 src/main/java/com/oronaminc/join/websocket/config/ParticipantEventHandler.java create mode 100644 src/main/java/com/oronaminc/join/websocket/config/ParticipantManager.java diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 4609c68..1220827 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -21,11 +21,16 @@ public enum ErrorCode { NOT_FOUND_ROOM("ROOM-001", "존재하지 않는 발표방입니다.", NOT_FOUND), BAD_REQUEST_ROOM_STARTED("ROOM-002", "시작 상태의 발표방은 수정 및 삭제할 수 없습니다.", BAD_REQUEST), BAD_REQUEST_UPDATE_STATUS("ROOM-003", "변경할 수 없는 상태입니다.", BAD_REQUEST), + UNAUTHORIZED_JOIN_ROOM("ROOM-004", "시작 전 방에 참가할 수 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_SUBSCRIBE_ROOM("ROOM-005", "시작 전 혹은 종료된 방에 참가할 수 없습니다.", UNAUTHORIZED), NOT_FOUND_PARTICIPANT("PARTICIPANT-001", "발표방에 존재하지 않는 회원입니다.", NOT_FOUND), UNAUTHORIZED_TEAM_GUEST("PARTICIPANT-002", "게스트는 팀이 될 수 없습니다.", UNAUTHORIZED), UNAUTHORIZED_UPDATE_AND_DELETE("PARTICIPANT-003", "발표방 수정 및 삭제 권한이 없습니다.", UNAUTHORIZED), UNAUTHORIZED_REPORT_READ("PARTICIPANT-004", "결과 리포트 조회 권한이 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_LIMIT_PARTICIPANT("PARTICIPANT-005", "인원이 가득 차 참가할 수 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-005", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), + FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), @@ -48,11 +53,15 @@ public enum ErrorCode { SOCKET_ERROR("SOCKET-3000", "웹소켓 연결 중 서버 오류가 발생했습니다.", INTERNAL_SERVER_ERROR), SOCKET_RUNTIME_ERROR("SOCKET-2000", "처리되지 않은 오류가 발생했습니다", INTERNAL_SERVER_ERROR), SOCKET_VALIDATION_ERROR("SOCKET-1001", "입력값이 유효하지 않습니다.", BAD_REQUEST), + SOCKET_BAD_REQUEST_PATH("SOCKET-1002", "경로가 유효하지 않습니다.", BAD_REQUEST), + SOCKET_BAD_REQUEST_MEMBER("SOCKET-1003", "회원이 유효하지 않습니다.", BAD_REQUEST), + CONFLICT_EMOJI("EMOJI-001", "공감 처리 중 충돌이 발생했습니다.", CONFLICT), NOT_FOUND_EMOJI("EMOJI-002", "해당 이모지가 존재하지 않습니다.", NOT_FOUND), TOO_MANY_REQUESTS_EMOJI("EMOJI-003", "잠시 후 다시 시도해주세요.", TOO_MANY_REQUESTS), - ALREADY_EXISTS_EMOJI("EMOJI-004", "이미 해당 이모지가 존재합니다.", CONFLICT); + ALREADY_EXISTS_EMOJI("EMOJI-004", "이미 해당 이모지가 존재합니다.", CONFLICT) + ; private final String code; private final String message; diff --git a/src/main/java/com/oronaminc/join/room/domain/RoomStatus.java b/src/main/java/com/oronaminc/join/room/domain/RoomStatus.java index 0deff29..c4f71bc 100644 --- a/src/main/java/com/oronaminc/join/room/domain/RoomStatus.java +++ b/src/main/java/com/oronaminc/join/room/domain/RoomStatus.java @@ -1,5 +1,13 @@ package com.oronaminc.join.room.domain; +import lombok.AllArgsConstructor; + +@AllArgsConstructor public enum RoomStatus { - BEFORE_START, STARTED, ENDED + BEFORE_START(false), + STARTED(true), + ENDED(false) + ; + + public final Boolean canSubscribeRoom; } diff --git a/src/main/java/com/oronaminc/join/room/dto/RoomExitEvent.java b/src/main/java/com/oronaminc/join/room/dto/RoomExitEvent.java new file mode 100644 index 0000000..e70cf30 --- /dev/null +++ b/src/main/java/com/oronaminc/join/room/dto/RoomExitEvent.java @@ -0,0 +1,7 @@ +package com.oronaminc.join.room.dto; + +public record RoomExitEvent( + Long memberId, + Long roomId +) { +} diff --git a/src/main/java/com/oronaminc/join/room/dto/RoomJoinResponse.java b/src/main/java/com/oronaminc/join/room/dto/RoomJoinResponse.java new file mode 100644 index 0000000..64f6fa7 --- /dev/null +++ b/src/main/java/com/oronaminc/join/room/dto/RoomJoinResponse.java @@ -0,0 +1,6 @@ +package com.oronaminc.join.room.dto; + +public record RoomJoinResponse( + Integer participantCount +) { +} diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 69af3f1..f627f89 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -1,35 +1,46 @@ package com.oronaminc.join.room.service; +import static com.oronaminc.join.global.exception.ErrorCode.*; + import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.service.AnswerReader; -import com.oronaminc.join.infra.service.S3Service; -import com.oronaminc.join.participant.service.ParticipantReader; -import com.oronaminc.join.question.domain.Question; -import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.room.dto.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static com.oronaminc.join.global.exception.ErrorCode.*; - +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.document.domain.Document; import com.oronaminc.join.document.service.DocumentReader; import com.oronaminc.join.document.service.DocumentService; import com.oronaminc.join.emoji.service.EmojiService; import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.infra.service.S3Service; import com.oronaminc.join.participant.domain.Participant; import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.participant.service.ParticipantReader; import com.oronaminc.join.participant.service.ParticipantService; +import com.oronaminc.join.question.domain.Question; +import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.question.service.QuestionService; import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; +import com.oronaminc.join.room.dto.CreateRoomRequest; +import com.oronaminc.join.room.dto.CreateRoomResponse; +import com.oronaminc.join.room.dto.JoinRoomRequest; +import com.oronaminc.join.room.dto.JoinRoomResponse; +import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.dto.RoomDetailResponse; +import com.oronaminc.join.room.dto.RoomJoinResponse; +import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; +import com.oronaminc.join.room.dto.RoomUpdateRequest; +import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; +import com.oronaminc.join.room.dto.TopQnADto; import com.oronaminc.join.room.util.CodeGenerator; import com.oronaminc.join.room.util.RoomMapper; +import com.oronaminc.join.websocket.config.ParticipantManager; import lombok.RequiredArgsConstructor; @@ -48,13 +59,13 @@ public class RoomService { private final RoomReader roomReader; private final AnswerReader answerReader; private final ParticipantReader participantReader; - + private final QuestionReader questionReader; + private final ParticipantManager participantManager; private static final int CODE_LENGTH = 6; - private final QuestionReader questionReader; public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, - String presenterEmail) { + String presenterEmail) { String code = this.generateCode(); Room room = RoomMapper.toRoom(createRoomRequest, code); roomRepository.save(room); @@ -67,7 +78,9 @@ public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) { Room room = roomReader.getBySecretCode(joinRoomRequest.secretCode()); - + if (room.getRoomStatus().equals(RoomStatus.STARTED)) { + throw new ErrorException(UNAUTHORIZED_JOIN_ROOM); + } participantService.saveParticipantById(memberId, room, ParticipantType.GUEST); return new JoinRoomResponse(room.getId()); } @@ -80,10 +93,10 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { Participant presenter = participantService.getPresenter(roomId); List team = participantService.getTeam(roomId); Document document = documentReader.getByRoomId(roomId); - + int participantCount = participantManager.getRoomParticipants(roomId).size(); String presignedUrl = s3Service.generatePresignedUrl(document.getFileUrl()); - return RoomMapper.toRoomDetailResponse(room, presenter, team, presignedUrl, memberId); + return RoomMapper.toRoomDetailResponse(room, presenter, team, presignedUrl, memberId, participantCount); } public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomRequest) { @@ -121,7 +134,7 @@ public void deleteRoom(Long memberId, Long roomId) { } public void updateRoomStatus(Long memberId, Long roomId, - RoomUpdateStatusRequest roomUpdateStatusRequest) { + RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); @@ -164,8 +177,7 @@ public ReportResponse getRoomReport(Long roomId, Long memberId) { Double answerRate = calculateAnswerRate(totalQuestions, totalAnswerByQuestion); List topQnA = getTopQnA(roomId); - - return RoomMapper.toReportResponse(room, totalView,totalQuestions, answerRate, topQnA); + return RoomMapper.toReportResponse(room, totalView, totalQuestions, answerRate, topQnA); } private List getTopQnA(Long roomId) { @@ -188,7 +200,7 @@ private List getTopQnA(Long roomId) { )); return top3Question.stream() - .map( q -> new TopQnADto( + .map(q -> new TopQnADto( q.getContent(), q.getEmojiCount(), answersByQuestionId.getOrDefault(q.getId(), List.of()) @@ -199,7 +211,18 @@ private List getTopQnA(Long roomId) { private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuestion) { return (totalQuestions == 0) ? 0.0 - : ((double) totalAnswerByQuestion / totalQuestions) * 100; + : ((double)totalAnswerByQuestion / totalQuestions) * 100; + + } + public RoomJoinResponse subscribeRoom(Long roomId, Long memberId) { + participantService.validateParticipant(memberId, roomId); + Room room = roomReader.getById(roomId); + if (!room.getRoomStatus().canSubscribeRoom) { + throw new ErrorException(UNAUTHORIZED_SUBSCRIBE_ROOM); + } + Integer limit = room.getParticipantLimit(); + participantManager.addParticipant(roomId, memberId, limit); + return new RoomJoinResponse(participantManager.getRoomParticipants(roomId).size()); } } diff --git a/src/main/java/com/oronaminc/join/room/util/RoomMapper.java b/src/main/java/com/oronaminc/join/room/util/RoomMapper.java index d2df184..c0e7b76 100644 --- a/src/main/java/com/oronaminc/join/room/util/RoomMapper.java +++ b/src/main/java/com/oronaminc/join/room/util/RoomMapper.java @@ -3,12 +3,16 @@ import java.time.LocalTime; import java.util.List; -import com.oronaminc.join.document.domain.Document; import com.oronaminc.join.participant.domain.Participant; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.domain.RoomType; -import com.oronaminc.join.room.dto.*; +import com.oronaminc.join.room.dto.CreateRoomRequest; +import com.oronaminc.join.room.dto.CreateRoomResponse; +import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.dto.RoomDetailResponse; +import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; +import com.oronaminc.join.room.dto.TopQnADto; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -33,7 +37,7 @@ public static CreateRoomResponse toCreateRoomResponse(Room room) { return new CreateRoomResponse(room.getId(), room.getSecretCode()); } - public static RoomDetailResponse toRoomDetailResponse(Room room, Participant presenter, List team, String presignedUrl, Long memberId) { + public static RoomDetailResponse toRoomDetailResponse(Room room, Participant presenter, List team, String presignedUrl, Long memberId, int participantCount) { return RoomDetailResponse.builder() .title(room.getTitle()) .description(room.getDescription()) @@ -41,7 +45,7 @@ public static RoomDetailResponse toRoomDetailResponse(Room room, Participant pre .team(team.stream().map(participant -> participant.getMember().getNickname()).toList()) .roomCode(room.getSecretCode()) .presignedUrl(presignedUrl) - .participantCount(0) + .participantCount(participantCount) .participantLimit(room.getParticipantLimit()) .emojiCount(room.getEmojiCount()) .isHost(presenter.getMember().getId().equals(memberId)) diff --git a/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java new file mode 100644 index 0000000..4a4f3c1 --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java @@ -0,0 +1,41 @@ +package com.oronaminc.join.websocket.api; + +import java.security.Principal; + +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Controller; + +import com.oronaminc.join.room.dto.RoomJoinResponse; +import com.oronaminc.join.room.service.RoomService; +import com.oronaminc.join.websocket.config.WebsocketSessionManager; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class RoomWebsocketController { + private final RoomService roomService; + private final WebsocketSessionManager sessionManager; + + @MessageMapping("/rooms/{roomId}/join") + @SendTo("/topic/rooms/{roomId}/join") + public RoomJoinResponse joinRoom( + @DestinationVariable Long roomId, + Principal principal, + Message message + ) { + Long memberId = Long.valueOf(principal.getName()); + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + String sessionId = accessor.getSessionId(); + + // 세션 매니저에서 세션 정보 활용 + RoomJoinResponse response = roomService.subscribeRoom(roomId, memberId); + sessionManager.addAttribute(sessionId, "roomId", roomId); + + return response; + } +} diff --git a/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java b/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java index 832fda9..a926212 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java @@ -1,13 +1,8 @@ package com.oronaminc.join.websocket.api; -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.global.exception.ErrorResponse; -import com.oronaminc.join.websocket.config.WebsocketSessionManager; import java.io.IOException; import java.net.SocketException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.messaging.Message; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.simp.annotation.SendToUser; @@ -15,6 +10,14 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.global.exception.ErrorResponse; +import com.oronaminc.join.websocket.config.WebsocketSessionManager; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j @ControllerAdvice @RequiredArgsConstructor @@ -45,6 +48,7 @@ public ErrorResponse handleCustomException(ErrorException e, Message message) StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); log.info("에러를 보낼 세션 ID: {}", accessor.getSessionId()); + log.info("에러: {} - {}", e.getErrorCode(), e.getMessage()); return new ErrorResponse(e.getErrorCode()); } diff --git a/src/main/java/com/oronaminc/join/websocket/config/CustomWebSocketHandlerDecorator.java b/src/main/java/com/oronaminc/join/websocket/config/CustomWebSocketHandlerDecorator.java index 7a32702..9f4ccc4 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/CustomWebSocketHandlerDecorator.java +++ b/src/main/java/com/oronaminc/join/websocket/config/CustomWebSocketHandlerDecorator.java @@ -1,36 +1,62 @@ package com.oronaminc.join.websocket.config; +import java.security.Principal; +import java.util.Objects; + +import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.WebSocketHandlerDecorator; +import com.oronaminc.join.room.dto.RoomExitEvent; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j public class CustomWebSocketHandlerDecorator extends WebSocketHandlerDecorator { -// 연결된 세션 관리 + private static final String ATTRIBUTES_ROOM_ID_KEY = "roomId"; + // 연결된 세션 관리 private final WebsocketSessionManager sessionManager; + private final ApplicationEventPublisher publisher; public CustomWebSocketHandlerDecorator(WebSocketHandler delegate, - WebsocketSessionManager sessionManager) { + WebsocketSessionManager sessionManager, + ApplicationEventPublisher publisher + ) { super(delegate); this.sessionManager = sessionManager; + this.publisher = publisher; } @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 세션 연결되면 map에 저장 - sessionManager.registerSession(session); super.afterConnectionEstablished(session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) - throws Exception { + throws Exception { // 세션 연결 종료되면 map에서 제거 - + exitRoomPublishEvent(session); sessionManager.removeSession(session.getId()); super.afterConnectionClosed(session, closeStatus); } + private void exitRoomPublishEvent(WebSocketSession session) { + Principal principal = Objects.requireNonNull(session.getPrincipal()); + Long memberId = Long.valueOf(principal.getName()); + + Object value = session.getAttributes().get(ATTRIBUTES_ROOM_ID_KEY); + if (value == null) { + return; + } + Long roomId = (Long) value; + + publisher.publishEvent(new RoomExitEvent(memberId, roomId)); + } + } diff --git a/src/main/java/com/oronaminc/join/websocket/config/ParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/config/ParticipantEventHandler.java new file mode 100644 index 0000000..c798c5e --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/config/ParticipantEventHandler.java @@ -0,0 +1,80 @@ +package com.oronaminc.join.websocket.config; + +import static com.oronaminc.join.global.exception.ErrorCode.*; + +import java.security.Principal; +import java.util.Set; + +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.room.dto.RoomExitEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ParticipantEventHandler { + private final ParticipantManager participantManager; + + private static final String ROOM_PREFIX = "/topic/rooms/"; + private static final String JOIN_SUFFIX = "/join"; + + @EventListener + public void handleSubscribe(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + Principal principal = accessor.getUser(); + + if (destination == null) { + throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + } + + if (!destination.startsWith(ROOM_PREFIX)) { + return; + } + + Long memberId = parseMemberId(principal); + Long roomId = parseRoomId(destination); + + if (!isRoomJoinPath(destination)) { + validateParticipantRoomJoin(roomId, memberId); + } + } + + @EventListener + public void handleUnsubscribe(RoomExitEvent event) { + participantManager.removeParticipant(event.memberId(), event.roomId()); + } + + private boolean isRoomJoinPath(String destination) { + return destination.startsWith(ROOM_PREFIX) && destination.endsWith(JOIN_SUFFIX); + } + + private void validateParticipantRoomJoin(Long roomId, Long memberId) { + Set participants = participantManager.getRoomParticipants(roomId); + if (participants == null || !participants.contains(memberId)) { + throw new ErrorException(UNAUTHORIZED_NOT_JOIN_ROOM); + } + } + + private Long parseRoomId(String destination) { + try { + String[] parts = destination.split("/"); + return Long.valueOf(parts[3]); + } catch (Exception e) { + throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + } + } + + private Long parseMemberId(Principal principal) { + try { + return Long.valueOf(principal.getName()); + } catch (Exception e) { + throw new ErrorException(SOCKET_BAD_REQUEST_MEMBER); + } + } +} diff --git a/src/main/java/com/oronaminc/join/websocket/config/ParticipantManager.java b/src/main/java/com/oronaminc/join/websocket/config/ParticipantManager.java new file mode 100644 index 0000000..6e999ba --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/config/ParticipantManager.java @@ -0,0 +1,42 @@ +package com.oronaminc.join.websocket.config; + +import static com.oronaminc.join.global.exception.ErrorCode.*; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +import com.oronaminc.join.global.exception.ErrorException; + +@Component +public class ParticipantManager { + private final Map> roomParticipants = new ConcurrentHashMap<>(); + + public Set getRoomParticipants(Long roomId) { + createRoom(roomId); + return roomParticipants.get(roomId); + } + + public void createRoom(Long roomId) { + roomParticipants.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()); + } + + public void addParticipant(Long roomId, Long memberId, int limit) { + Set participants = getRoomParticipants(roomId); + + if (participants.contains(memberId)) return; + + synchronized (participants) { + if (participants.size() >= limit) { + throw new ErrorException(UNAUTHORIZED_LIMIT_PARTICIPANT); + } + participants.add(memberId); + } + } + + public void removeParticipant(Long memberId, Long roomId) { + roomParticipants.get(roomId).remove(memberId); + } +} diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index b6380be..f423a25 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -1,15 +1,18 @@ package com.oronaminc.join.websocket.config; -import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; +import lombok.RequiredArgsConstructor; + @Configuration @RequiredArgsConstructor @EnableWebSocketMessageBroker @@ -17,11 +20,15 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final CustomHandshakeHandler handshakeHandler; private final StompErrorHandler stompErrorHandler; + private final WebsocketSessionManager sessionManager; + private final ApplicationEventPublisher publisher; @Bean public WebSocketHandlerDecoratorFactory webSocketHandlerDecoratorFactory( - WebsocketSessionManager sessionManager) { - return delegate -> new CustomWebSocketHandlerDecorator(delegate, sessionManager); + WebsocketSessionManager sessionManager, + ApplicationEventPublisher publisher + ) { + return delegate -> new CustomWebSocketHandlerDecorator(delegate, sessionManager, publisher); } @Override @@ -34,18 +41,23 @@ public void configureMessageBroker(MessageBrokerRegistry config) { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*") - // websocket 연결 전 쿠키 체크 - .addInterceptors(new HttpSessionHandshakeInterceptor()) - // websocket 연결 후 principal 생성 - .setHandshakeHandler(handshakeHandler) - .withSockJS(); + .setAllowedOriginPatterns("*") + // websocket 연결 전 쿠키 체크 + .addInterceptors(new HttpSessionHandshakeInterceptor()) + // websocket 연결 후 principal 생성 + .setHandshakeHandler(handshakeHandler) + .withSockJS(); registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*") - .addInterceptors(new HttpSessionHandshakeInterceptor()) - .setHandshakeHandler(handshakeHandler); + .setAllowedOriginPatterns("*") + .addInterceptors(new HttpSessionHandshakeInterceptor()) + .setHandshakeHandler(handshakeHandler); registry.setErrorHandler(stompErrorHandler); } + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registry) { + registry.setDecoratorFactories(webSocketHandlerDecoratorFactory(sessionManager, publisher)); + } } diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebsocketSessionManager.java b/src/main/java/com/oronaminc/join/websocket/config/WebsocketSessionManager.java index 3b7ec8a..79ea36c 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebsocketSessionManager.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebsocketSessionManager.java @@ -30,4 +30,8 @@ public void closeSession(String sessionId) throws IOException { } + public void addAttribute(String sessionId, String attributeName, Object attributeValue) { + WebSocketSession session = sessions.get(sessionId); + session.getAttributes().put(attributeName, attributeValue); + } } From aa6d72ec408a4f2f9fa470f895cb779a48d7c697 Mon Sep 17 00:00:00 2001 From: chcch529 <146617430+chcch529@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:38:08 +0900 Subject: [PATCH 06/74] =?UTF-8?q?refactor:=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EC=97=90=20queryDsl=20=EC=A0=81=EC=9A=A9=20(?= =?UTF-8?q?#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: 의존성 추가 * refactor: querydsl 적용 * refactor: 서비스 코드 수정 * test: 테스트 코드 수정 * style: 불필요한 import 제거 * test: repo 테스트들 config 추가 --- build.gradle | 7 ++ .../join/global/config/QueryDslConfig.java | 20 ++++ .../dao/QuestionCustomRepository.java | 12 ++ .../dao/QuestionCustomRepositoryImpl.java | 103 ++++++++++++++++++ .../join/question/dao/QuestionRepository.java | 91 +--------------- .../join/question/service/QuestionReader.java | 39 +++---- .../question/service/QuestionService.java | 35 +++--- .../join/config/TestQueryDslConfig.java | 19 ++++ .../join/emoji/service/EmojiFacadeTests.java | 3 +- .../dao/ParticipantRepositoryTests.java | 3 + .../question/dao/QuestionRepositoryTests.java | 19 ++-- .../service/QuestionServiceTests.java | 11 +- 12 files changed, 215 insertions(+), 147 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/config/QueryDslConfig.java create mode 100644 src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepository.java create mode 100644 src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepositoryImpl.java create mode 100644 src/test/java/com/oronaminc/join/config/TestQueryDslConfig.java diff --git a/build.gradle b/build.gradle index cb4e4e0..5938904 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,13 @@ dependencies { // S3 implementation 'software.amazon.awssdk:s3:2.31.77' + + // querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' } diff --git a/src/main/java/com/oronaminc/join/global/config/QueryDslConfig.java b/src/main/java/com/oronaminc/join/global/config/QueryDslConfig.java new file mode 100644 index 0000000..9ea16a7 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/QueryDslConfig.java @@ -0,0 +1,20 @@ +package com.oronaminc.join.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } + +} diff --git a/src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepository.java b/src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepository.java new file mode 100644 index 0000000..d117c21 --- /dev/null +++ b/src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepository.java @@ -0,0 +1,12 @@ +package com.oronaminc.join.question.dao; + +import com.oronaminc.join.question.domain.QuestionSort; +import com.oronaminc.join.question.dto.QuestionFlatResponse; +import java.util.List; +import org.springframework.data.domain.Pageable; + +public interface QuestionCustomRepository { + + List findQuestionsOrderBy(Long lastId, Long lastEmojiCount, + Long memberId, Long roomId, QuestionSort sortType, Pageable pageable); +} diff --git a/src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepositoryImpl.java b/src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepositoryImpl.java new file mode 100644 index 0000000..8f2703b --- /dev/null +++ b/src/main/java/com/oronaminc/join/question/dao/QuestionCustomRepositoryImpl.java @@ -0,0 +1,103 @@ +package com.oronaminc.join.question.dao; + +import static com.oronaminc.join.answer.domain.QAnswer.answer; +import static com.oronaminc.join.emoji.domain.QEmoji.emoji; +import static com.oronaminc.join.member.domain.QMember.member; +import static com.oronaminc.join.question.domain.QQuestion.question; + +import com.oronaminc.join.question.domain.QuestionSort; +import com.oronaminc.join.question.dto.QuestionFlatResponse; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +@RequiredArgsConstructor +public class QuestionCustomRepositoryImpl implements QuestionCustomRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findQuestionsOrderBy(Long lastId, Long lastEmojiCount, + Long memberId, Long roomId, QuestionSort sortType, Pageable pageable) { + + Predicate where = createPredicate(lastId, lastEmojiCount, memberId, roomId, + sortType); + + JPAQuery query = jpaQueryFactory + .select(Projections.constructor(QuestionFlatResponse.class, + question.id, + question.content, + question.emojiCount, + JPAExpressions.selectOne().from(answer).where(answer.question.eq(question)) + .exists(), + JPAExpressions.selectOne().from(emoji).where(emoji.member.id.eq(memberId)).exists(), + member.id, + member.nickname, + question.createdAt + )) + .from(question) + .join(question.member, member) + .where(where) + .orderBy(createOrderSpecifiers(sortType)); + + return query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + private Predicate createPredicate(Long lastId, Long lastEmojiCount, + Long memberId, Long roomId, QuestionSort sortType) { + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(question.room.id.eq(roomId)); + + switch (sortType) { + case CREATEDAT -> { + if (lastId != null) { + builder.and(question.id.lt(lastId)); + } + } + case EMOJI -> { + if (lastEmojiCount != null) { + builder.and( + question.emojiCount.lt(lastEmojiCount) + .or(question.emojiCount.eq(lastEmojiCount).and(question.id.lt(lastId))) + ); + } + } + case MYQUESTION -> { + builder.and(question.member.id.eq(memberId)); + if (lastId != null) { + builder.and(question.id.lt(lastId)); + } + } + } + + return builder; + } + + private OrderSpecifier[] createOrderSpecifiers(QuestionSort sortType) { + + List> orders = new ArrayList<>(); + + // 공감순인 경우에만 emojiCount 정렬 조건 추가 + if (sortType.equals(QuestionSort.EMOJI)) { + orders.add(new OrderSpecifier<>(Order.DESC, question.emojiCount)); + } + + // 최신순, 내 질문 + orders.add(new OrderSpecifier<>(Order.DESC, question.id)); + + return orders.toArray(new OrderSpecifier[0]); + } +} diff --git a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java index af8af3f..b8e3eaa 100644 --- a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java +++ b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java @@ -2,7 +2,6 @@ import com.oronaminc.join.question.domain.Question; import java.util.Optional; -import com.oronaminc.join.question.dto.QuestionFlatResponse; import java.util.List; import com.oronaminc.join.room.dto.TopQnADto; @@ -11,98 +10,10 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface QuestionRepository extends JpaRepository { +public interface QuestionRepository extends JpaRepository, QuestionCustomRepository { Optional findByIdAndRoomId(Long questionId, Long roomId); - @Query(""" - SELECT new com.oronaminc.join.question.dto.QuestionFlatResponse( - q.id, - q.content, - q.emojiCount, - (CASE WHEN EXISTS ( - SELECT a FROM Answer a - WHERE a.question = q - ) THEN true ELSE false END), - (CASE WHEN EXISTS ( - SELECT e FROM Emoji e - WHERE e.member.id = :memberId - AND e.targetType = com.oronaminc.join.emoji.domain.TargetType.QUESTION - AND e.targetId = q.id - ) THEN true ELSE false END), - m.id, - m.nickname, - q.createdAt - ) - FROM Question q - JOIN q.member m - WHERE :roomId = q.room.id - AND (:lastId IS NULL OR q.id < :lastId) - ORDER BY q.id DESC - """) - List findByCreatedAt(@Param("lastId") Long lastId, - @Param("memberId") Long memberId, @Param("roomId") Long roomId, Pageable pageable); - - @Query(""" - SELECT new com.oronaminc.join.question.dto.QuestionFlatResponse( - q.id, - q.content, - q.emojiCount, - (CASE WHEN EXISTS ( - SELECT a FROM Answer a - WHERE a.question = q - ) THEN true ELSE false END), - (CASE WHEN EXISTS ( - SELECT e FROM Emoji e - WHERE e.member.id = :memberId - AND e.targetType = com.oronaminc.join.emoji.domain.TargetType.QUESTION - AND e.targetId = q.id - ) THEN true ELSE false END), - m.id, - m.nickname, - q.createdAt - ) - FROM Question q - JOIN q.member m - WHERE :roomId = q.room.id - AND (:lastEmojiCount IS NULL OR ( - q.emojiCount < :lastEmojiCount OR (q.emojiCount = :lastEmojiCount AND q.id < :lastId) - )) - ORDER BY q.emojiCount DESC, q.id DESC - """) - List findByEmojiCount(@Param("lastId") Long lastId, - @Param("lastEmojiCount") Long lastEmojiCount, - @Param("memberId") Long memberId, @Param("roomId") Long roomId, Pageable pageable); - - @Query(""" - SELECT new com.oronaminc.join.question.dto.QuestionFlatResponse( - q.id, - q.content, - q.emojiCount, - (CASE WHEN EXISTS ( - SELECT a FROM Answer a - WHERE a.question = q - ) THEN true ELSE false END), - (CASE WHEN EXISTS ( - SELECT e FROM Emoji e - WHERE e.member.id = :memberId - AND e.targetType = com.oronaminc.join.emoji.domain.TargetType.QUESTION - AND e.targetId = q.id - ) THEN true ELSE false END), - m.id, - m.nickname, - q.createdAt - ) - FROM Question q - JOIN q.member m - WHERE :roomId = q.room.id - AND q.member.id = :memberId - AND (:lastId IS NULL OR q.id > :lastId) - ORDER BY q.id DESC - """) - List findByMyQuestion(@Param("lastId") Long lastId, - @Param("memberId") Long memberId, @Param("roomId") Long roomId, Pageable pageable); - @Query(""" select q.room.id, count(q) from Question q diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java index 0b0acb1..8850ff3 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java @@ -1,24 +1,23 @@ package com.oronaminc.join.question.service; -import java.util.List; -import java.util.Optional; - -import com.oronaminc.join.room.dto.TopQnADto; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.question.dao.QuestionRepository; import com.oronaminc.join.question.domain.Question; +import com.oronaminc.join.question.domain.QuestionSort; import com.oronaminc.join.question.dto.QuestionFlatResponse; - +import com.oronaminc.join.room.dto.TopQnADto; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class QuestionReader { + private final QuestionRepository questionRepository; public Optional findById(Long questionId) { @@ -27,7 +26,7 @@ public Optional findById(Long questionId) { public Question getById(Long questionId) { return this.findById(questionId) - .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_QUESTION)); + .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_QUESTION)); } public Optional findByIdAndRoomId(Long questionId, Long roomId) { @@ -36,19 +35,13 @@ public Optional findByIdAndRoomId(Long questionId, Long roomId) { public Question getByIdAndRoomId(Long questionId, Long roomId) { return this.findByIdAndRoomId(questionId, roomId) - .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_ROOM_QUESTION)); + .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_ROOM_QUESTION)); } - public List findByCreatedAt(Long lastId, Long memberId, Long roomId, Pageable pageable) { - return questionRepository.findByCreatedAt(lastId, memberId, roomId, pageable); - } - - public List findByEmojiCount(Long lastId, Long lastEmojiCount, Long memberId, Long roomId, Pageable pageable) { - return questionRepository.findByEmojiCount(lastId, lastEmojiCount, memberId, roomId, pageable); - } - - public List findByMyQuestion(Long lastId, Long memberId, Long roomId, Pageable pageable) { - return questionRepository.findByMyQuestion(lastId, memberId, roomId, pageable); + public List findQuestionsOrderBy(Long lastId, Long lastEmojiCount, + Long memberId, Long roomId, QuestionSort sortType, Pageable pageable) { + return questionRepository.findQuestionsOrderBy(lastId, lastEmojiCount, + memberId, roomId, sortType, pageable); } public List findByRoomId(Long roomId) { @@ -63,7 +56,9 @@ public boolean existsInRoom(Long roomId) { return !questionRepository.findByRoomId(roomId).isEmpty(); } - public Long countByRoomId(Long roomId) { return questionRepository.countByRoomId(roomId);} + public Long countByRoomId(Long roomId) { + return questionRepository.countByRoomId(roomId); + } public List findTop3Question(Long roomId) { return questionRepository.findTop3QuestionByRoomId(roomId, PageRequest.of(0,3)); diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionService.java b/src/main/java/com/oronaminc/join/question/service/QuestionService.java index 843f2e1..f49d768 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionService.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionService.java @@ -1,20 +1,12 @@ package com.oronaminc.join.question.service; +import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.participant.service.ParticipantReader; -import java.util.List; - -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.util.SliceUtil; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.participant.service.ParticipantReader; import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.dao.QuestionRepository; import com.oronaminc.join.question.domain.Question; @@ -25,8 +17,13 @@ import com.oronaminc.join.question.util.QuestionMapper; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; - +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -70,14 +67,9 @@ public Slice getQuestions( Pageable pageable = PageRequest.of(0, size + 1); - List questions = switch (sort) { - case QuestionSort.CREATEDAT -> questionReader.findByCreatedAt(lastId, - memberId, roomId, pageable); - case QuestionSort.EMOJI -> questionReader.findByEmojiCount(lastId, - lastEmojiCount, memberId, roomId, pageable); - case QuestionSort.MYQUESTION -> questionReader.findByMyQuestion(lastId, - memberId, roomId, pageable); - }; + List questions = questionReader.findQuestionsOrderBy(lastId, + lastEmojiCount, memberId, roomId, sort, + pageable); List assembledList = questions.stream() .map(QuestionMapper::toQuestionListResponse).toList(); @@ -86,7 +78,8 @@ public Slice getQuestions( } @Transactional - public Question update(Long memberId, Long roomId, Long questionId, QuestionCreateRequest request) { + public Question update(Long memberId, Long roomId, Long questionId, + QuestionCreateRequest request) { Question question = questionReader.getByIdAndRoomId(questionId, roomId); // 참여자가 아님 @@ -115,7 +108,7 @@ public Long delete(Long memberId, Long roomId, Long questionId) { // 관리자가 아님 && 작성자도 아님 if (!participantReader.existsPresenterOrTeamByMemberId(roomId, memberId) - && !question.getMember().getId().equals(memberId)) { + && !question.getMember().getId().equals(memberId)) { throw new ErrorException(ErrorCode.UNAUTHORIZED_DELETE_QUESTION); } diff --git a/src/test/java/com/oronaminc/join/config/TestQueryDslConfig.java b/src/test/java/com/oronaminc/join/config/TestQueryDslConfig.java new file mode 100644 index 0000000..8f483d7 --- /dev/null +++ b/src/test/java/com/oronaminc/join/config/TestQueryDslConfig.java @@ -0,0 +1,19 @@ +package com.oronaminc.join.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestQueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java index a872793..aeb3d4c 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.oronaminc.join.answer.service.AnswerReader; +import com.oronaminc.join.config.TestQueryDslConfig; import com.oronaminc.join.emoji.dao.EmojiRepository; import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; @@ -32,7 +33,7 @@ @DataJpaTest @Import({EmojiFacade.class, EmojiService.class, MemberReader.class, EmojiReader.class, - RoomReader.class, QuestionReader.class, AnswerReader.class}) + RoomReader.class, QuestionReader.class, AnswerReader.class, TestQueryDslConfig.class}) @ActiveProfiles("test") class EmojiFacadeTests { diff --git a/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java b/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java index 802e6f7..8193047 100644 --- a/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java +++ b/src/test/java/com/oronaminc/join/participant/dao/ParticipantRepositoryTests.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.oronaminc.join.config.TestQueryDslConfig; import com.oronaminc.join.member.dao.MemberRepository; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.dto.ParticipantCountDto; @@ -15,6 +16,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -22,6 +24,7 @@ @DataJpaTest @ActiveProfiles("test") +@Import(TestQueryDslConfig.class) class ParticipantRepositoryTests { @Autowired diff --git a/src/test/java/com/oronaminc/join/question/dao/QuestionRepositoryTests.java b/src/test/java/com/oronaminc/join/question/dao/QuestionRepositoryTests.java index ba83258..dd144eb 100644 --- a/src/test/java/com/oronaminc/join/question/dao/QuestionRepositoryTests.java +++ b/src/test/java/com/oronaminc/join/question/dao/QuestionRepositoryTests.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import com.oronaminc.join.config.TestQueryDslConfig; +import com.oronaminc.join.question.domain.QuestionSort; import com.oronaminc.join.question.dto.QuestionFlatResponse; import java.util.Comparator; import java.util.List; @@ -9,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.test.context.ActiveProfiles; @@ -16,6 +19,7 @@ @DataJpaTest @ActiveProfiles("test") +@Import(TestQueryDslConfig.class) @Sql(scripts = "/question-test-data.sql") class QuestionRepositoryTests { @@ -31,9 +35,8 @@ void findByCreatedAt_success() { Long roomId = 1L; // when - List result = questionRepository.findByCreatedAt( - null, memberId, roomId, pageable - ); + List result = questionRepository.findQuestionsOrderBy( + null, null, memberId, roomId, QuestionSort.CREATEDAT, pageable); // then assertThat(result).hasSize(5); @@ -50,9 +53,8 @@ void findByEmoji_success() { Long roomId = 1L; // when - List result = questionRepository.findByEmojiCount( - null, null, memberId, roomId, pageable - ); + List result = questionRepository.findQuestionsOrderBy( + null, null, memberId, roomId, QuestionSort.EMOJI, pageable); // then assertThat(result).hasSize(5); @@ -70,9 +72,8 @@ void findByMyQuestion_success() { Long roomId = 1L; // when - List result = questionRepository.findByMyQuestion( - null, memberId, roomId, pageable - ); + List result = questionRepository.findQuestionsOrderBy( + null, null, memberId, roomId, QuestionSort.MYQUESTION, pageable); // then assertThat(result).hasSize(5); diff --git a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java index aeb38f4..a1617ff 100644 --- a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java +++ b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java @@ -37,6 +37,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.test.context.ActiveProfiles; @@ -204,12 +205,13 @@ void getQuestionByCreatedAt_success() { Long roomId = 1L; Long memberId = 1L; int size = 1; + Pageable pageable = PageRequest.of(0, size + 1); List mockList = List.of(mockQ1, mockQ2); given(memberReader.getById(memberId)).willReturn(mockMember); given(roomReader.getById(roomId)).willReturn(mockRoom); - given(questionReader.findByCreatedAt(null, memberId, roomId, PageRequest.of(0, size + 1))) + given(questionReader.findQuestionsOrderBy(null, null, memberId, roomId, QuestionSort.CREATEDAT, pageable)) .willReturn(mockList); Slice result = questionService.getQuestions( @@ -227,13 +229,13 @@ void getQuestionByEmoji_success() { Long roomId = 1L; Long memberId = 1L; int size = 1; + Pageable pageable = PageRequest.of(0, size + 1); List mockList = List.of(mockQ1, mockQ2); given(memberReader.getById(memberId)).willReturn(mockMember); given(roomReader.getById(roomId)).willReturn(mockRoom); - given(questionReader.findByEmojiCount(null, null, memberId, roomId, - PageRequest.of(0, size + 1))) + given(questionReader.findQuestionsOrderBy(null, null, memberId, roomId, QuestionSort.EMOJI, pageable)) .willReturn(mockList); Slice result = questionService.getQuestions(QuestionSort.EMOJI, @@ -250,12 +252,13 @@ void getQuestionByMyQuestion_success() { Long roomId = 1L; Long memberId = 1L; int size = 1; + Pageable pageable = PageRequest.of(0, size + 1); List mockList = List.of(mockQ1, mockQ2); given(memberReader.getById(memberId)).willReturn(mockMember); given(roomReader.getById(roomId)).willReturn(mockRoom); - given(questionReader.findByMyQuestion(null, memberId, roomId, PageRequest.of(0, size + 1))) + given(questionReader.findQuestionsOrderBy(null, null, memberId, roomId, QuestionSort.MYQUESTION, pageable)) .willReturn(mockList); Slice result = questionService.getQuestions( From 01394fae885013a9496fc9913ae777300a0fe51e Mon Sep 17 00:00:00 2001 From: SeungTae <122506273+gffd94@users.noreply.github.com> Date: Thu, 17 Jul 2025 09:27:00 +0900 Subject: [PATCH 07/74] =?UTF-8?q?feat=20:=20=EB=8B=B5=EB=B3=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=82=AD=EC=A0=9C=20api=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 답변 수정/삭제 구현 * 답변 수정 삭제 테스트 작성 --- .../join/answer/api/AnswerController.java | 11 ++ .../oronaminc/join/answer/domain/Answer.java | 5 +- .../join/answer/dto/AnswerCreateRequest.java | 11 -- .../join/answer/dto/AnswerCreateResponse.java | 4 + .../join/answer/dto/AnswerDeleteResponse.java | 12 ++ .../join/answer/dto/AnswerRequest.java | 16 +++ .../join/answer/dto/AnswerUpdateResponse.java | 17 +++ .../join/answer/mapper/AnswerMapper.java | 14 +- .../join/answer/service/AnswerService.java | 25 +++- .../join/answer/util/PermissionValidator.java | 44 +++++- .../join/global/exception/ErrorCode.java | 2 + .../api/AnswerWebsocketController.java | 57 +++++++- .../join/answer/api/PermissionValidTests.java | 132 +++++++++++++----- .../answer/service/AnswerServiceTests.java | 32 ++++- 14 files changed, 313 insertions(+), 69 deletions(-) delete mode 100644 src/main/java/com/oronaminc/join/answer/dto/AnswerCreateRequest.java create mode 100644 src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java create mode 100644 src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java create mode 100644 src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java diff --git a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java index 8e2b1eb..cc6c30c 100644 --- a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java +++ b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java @@ -3,6 +3,8 @@ import com.oronaminc.join.answer.dto.AnswerGetResponse; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.member.security.MemberDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,6 +22,15 @@ public class AnswerController { private final AnswerService answerService; + @Operation( + summary = "답변 조회", + description = "답변 보기 클릭 시 질문에 대한 답변을 조회", + responses = { + @ApiResponse(responseCode = "200", description = "답변 조회 성공"), + @ApiResponse(responseCode = "400", description = "답변이 없는데 답변보기 버튼이 활성화 되어 잘못된 조회 접근") + + } + ) @GetMapping("/rooms/{roomId}/questions/{questionId}/answers") @ResponseStatus(HttpStatus.OK) public ResponseEntity getAnswer( diff --git a/src/main/java/com/oronaminc/join/answer/domain/Answer.java b/src/main/java/com/oronaminc/join/answer/domain/Answer.java index 3517046..fe782ee 100644 --- a/src/main/java/com/oronaminc/join/answer/domain/Answer.java +++ b/src/main/java/com/oronaminc/join/answer/domain/Answer.java @@ -1,6 +1,5 @@ package com.oronaminc.join.answer.domain; -import com.oronaminc.join.answer.dto.AnswerCreateRequest; import com.oronaminc.join.global.entity.BaseEntity; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; @@ -60,6 +59,10 @@ public static Answer create(Question question, Member member, String content) { .build(); } + public void updataContent(String content) { + this.content = content; + } + public Long incrementEmojiCount() { return ++this.emojiCount; } diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateRequest.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateRequest.java deleted file mode 100644 index 588b721..0000000 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.oronaminc.join.answer.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "WebSocket STOMP 통신 답변 요청 DTO") -public record AnswerCreateRequest( - //TODO: 빈값 or " " (space) 처리 - @Schema(description = "답변 내용", example = "답변입니다.") - String content -) { - -} diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java index 240f866..391426d 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java @@ -2,6 +2,8 @@ import com.oronaminc.join.global.dto.WriterDto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import lombok.Builder; @@ -16,6 +18,8 @@ public record AnswerCreateResponse( @Schema(description = "답변 ID", example = "11") Long answerId, @Schema(description = "답변 내용", example = "답변입니다.") + @NotBlank(message = "답변 내용을 입력해주시기 바랍니다.") + @Size(max = 300, message = "답변 내용은 최대 300자까지 입력할 수 있습니다.") String content, @Schema(description = "답변 내용에 대한 공감 수", example = "23") int emojiCount, diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java new file mode 100644 index 0000000..d8f4f64 --- /dev/null +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java @@ -0,0 +1,12 @@ +package com.oronaminc.join.answer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "답변 삭제 응답 DTO") +public record AnswerDeleteResponse( + Long answerId, + @Schema(description = "삭제 이벤트", example = "DELETE") + String event +) { + +} diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java new file mode 100644 index 0000000..f9c3ead --- /dev/null +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java @@ -0,0 +1,16 @@ +package com.oronaminc.join.answer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "답변 생성/수정 요청 DTO") +public record AnswerRequest( + //TODO: 빈값 or " " (space) 처리 + @NotBlank(message = "답변 내용을 입력해주시기 바랍니다.") + @Size(max = 300, message = "답변 내용은 최대 300자까지 입력할 수 있습니다.") + @Schema(description = "답변 내용", example = "답변입니다.") + String content +) { + +} diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java new file mode 100644 index 0000000..3248b78 --- /dev/null +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java @@ -0,0 +1,17 @@ +package com.oronaminc.join.answer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "답변 수정 응답 DTO") +public record AnswerUpdateResponse( + Long answerId, + @Schema(description = "수정 이벤트", example = "UPDATE") + String event, + @Schema(description = "수정된 내용", example = "수정된 답변입니다.") + String content + +) { + +} diff --git a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java index f523264..95dace3 100644 --- a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java @@ -1,9 +1,10 @@ package com.oronaminc.join.answer.mapper; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerCreateRequest; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerCreateResponse; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerUpdateResponse; import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; @@ -43,7 +44,16 @@ public static AnswerGetResponse toAnswerGetResponse(Answer answer, Long emojiCou .build(); } - public static Answer toEntity(Question question, Member member, AnswerCreateRequest request) { + public static Answer toEntity(Question question, Member member, AnswerRequest request) { return Answer.create(question, member, request.content()); } + + public static AnswerUpdateResponse toAnswerUpdateResponse(Answer answer) { + return AnswerUpdateResponse.builder() + .answerId(answer.getId()) + .event("UPDATE") + .content(answer.getContent()) + .build(); + } + } diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index f018a76..5895ec6 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -4,12 +4,11 @@ import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerCreateRequest; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerGetResponse; import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.service.EmojiReader; -import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.service.MemberReader; @@ -38,7 +37,7 @@ public class AnswerService { @Transactional public Answer create(Long roomId, Long memberId, Long questionId, - AnswerCreateRequest requestDto) { + AnswerRequest request) { Member member = memberReader.getById(memberId); Room room = roomReader.getById(roomId); @@ -50,7 +49,7 @@ public Answer create(Long roomId, Long memberId, Long questionId, throw new ErrorException(BADREQUEST_DUPLICATION_ANSWER); } - Answer answer = AnswerMapper.toEntity(question, member, requestDto); + Answer answer = AnswerMapper.toEntity(question, member, request); answerRepository.save(answer); @@ -71,12 +70,28 @@ public AnswerGetResponse getAnswer(Long roomId, Long questionId, Long memberId) return AnswerMapper.toAnswerGetResponse(answer, emojiCount, isEmojied); } + @Transactional + public Answer update(Long answerId, AnswerRequest request) { + Answer answer = answerReader.getById(answerId); + + answer.updataContent(request.content()); + + return answer; + } + + @Transactional + public void delete(Long answerId) { + Answer answer = answerReader.getById(answerId); + answerRepository.delete(answer); + } + + @Transactional public void deleteByQuestion(Long questionId) { answerRepository.deleteByQuestionId(questionId); } + @Transactional public void deleteByQuestionList(List questions) { answerRepository.deleteByQuestionIn(questions); } - } diff --git a/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java b/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java index 75293ad..294fe37 100644 --- a/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java +++ b/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java @@ -1,12 +1,17 @@ package com.oronaminc.join.answer.util; -import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_PARTICIPANT; +import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_DELETE_ANSWER; +import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_EDIT_ANSWER; import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_ROLE_ANSWER; +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.participant.dao.ParticipantRepository; import com.oronaminc.join.participant.domain.Participant; import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.participant.service.ParticipantReader; +import com.oronaminc.join.question.domain.Question; +import com.oronaminc.join.room.domain.Room; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,16 +19,43 @@ @RequiredArgsConstructor public class PermissionValidator { - private final ParticipantRepository participantRepository; + private final ParticipantReader participantReader; + private final AnswerReader answerReader; public void validateAnswerPermission(Long roomId, Long memberId) { - Participant participant = participantRepository.findByRoomIdAndMemberId(roomId,memberId) - .orElseThrow(() -> new ErrorException(NOT_FOUND_PARTICIPANT)); + // TODO: 생성시 조건 ( null 이면 안됨, " " 안됨, 팀원이나 발표자, 질문 작성자 본인만 생성가능 ) + Participant participant = participantReader.getByRoomIdAndMemberId(roomId, memberId); ParticipantType type = participant.getParticipantType(); - if(type == ParticipantType.GUEST){ + if (type == ParticipantType.GUEST) { throw new ErrorException(UNAUTHORIZED_ROLE_ANSWER); } } + public void validateAnswerUpdatePermission(Long answerId, Long memberId) { + Answer answer = answerReader.getById(answerId); + + if (!answer.getMember().getId().equals(memberId)) { + throw new ErrorException(UNAUTHORIZED_EDIT_ANSWER); + } + } + + public void validateAnswerDeletePermission(Long answerId, Long memberId) { + Answer answer = answerReader.getById(answerId); + + Room room = answer.getQuestion().getRoom(); + Question question = answer.getQuestion(); + + Participant participant = participantReader.getByRoomIdAndMemberId(room.getId(), memberId); + ParticipantType type = participant.getParticipantType(); + + boolean isQuestionWriter = question.getMember().getId().equals(memberId); + + if (!(isQuestionWriter || type == ParticipantType.TEAM + || type == ParticipantType.PRESENTER)) { + throw new ErrorException(UNAUTHORIZED_DELETE_ANSWER); + } + + } + } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 1220827..6449fbb 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -44,6 +44,8 @@ public enum ErrorCode { NOT_FOUND_EXIST_ANSWER("ANSWER-002", "해당 질문에 대한 답변이 존재하지 않습니다.", NOT_FOUND), NOT_FOUND_ANSWER("ANSWER-003", "답변이 존재하지 않습니다.", NOT_FOUND), BADREQUEST_DUPLICATION_ANSWER("ANSWER-004", "이미 답변한 질문입니다.", BAD_REQUEST), + UNAUTHORIZED_EDIT_ANSWER("ANSWER-005", "작성자가 아니면 해당 댓글을 수정할 수 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_DELETE_ANSWER("ANSWER-006", "작성자 혹은 팀원, 발표자가 아니면 해당 댓글을 삭제할 수 없습니다.", UNAUTHORIZED), ACCESS_DENIED_SESSION("SESSION-1201", "접근 권한이 없습니다.", FORBIDDEN), diff --git a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java index f0177b4..d87702b 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java @@ -1,15 +1,17 @@ package com.oronaminc.join.websocket.api; +import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_MEMBER; + import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerCreateRequest; import com.oronaminc.join.answer.dto.AnswerCreateResponse; +import com.oronaminc.join.answer.dto.AnswerDeleteResponse; +import com.oronaminc.join.answer.dto.AnswerRequest; +import com.oronaminc.join.answer.dto.AnswerUpdateResponse; import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.answer.util.PermissionValidator; -import com.oronaminc.join.member.dao.MemberRepository; -import com.oronaminc.join.participant.dao.ParticipantRepository; -import com.oronaminc.join.question.dao.QuestionRepository; -import com.oronaminc.join.room.dao.RoomRepository; +import com.oronaminc.join.global.exception.ErrorException; +import jakarta.validation.Valid; import java.security.Principal; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,10 +34,10 @@ public class AnswerWebsocketController { public AnswerCreateResponse create( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - @Payload AnswerCreateRequest request, + @Payload @Valid AnswerRequest request, Principal principal ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = getMemberId(principal); permissionValidator.validateAnswerPermission(roomId, memberId); @@ -46,4 +48,45 @@ public AnswerCreateResponse create( return AnswerMapper.toAnswerCreateResponse(answer); } + @MessageMapping("/answers/{answerId}/update") + @SendTo("/topic/rooms/{roomId}/answers") + public AnswerUpdateResponse update( + @DestinationVariable Long answerId, + @Payload @Valid AnswerRequest request, + Principal principal + ) { + + Long memberId = getMemberId(principal); + + permissionValidator.validateAnswerUpdatePermission(answerId, memberId); + + Answer answer = answerService.update(answerId, request); + + return AnswerMapper.toAnswerUpdateResponse(answer); + } + + @MessageMapping("/answers/{answerId}/delete") + @SendTo("/topic/rooms/{roomId}/answers") + public AnswerDeleteResponse delete( + @DestinationVariable Long roomId, + @DestinationVariable Long questionId, + @DestinationVariable Long answerId, + Principal principal + ) { + Long memberId = getMemberId(principal); + + permissionValidator.validateAnswerDeletePermission(answerId, memberId); + + answerService.delete(answerId); + + return new AnswerDeleteResponse(answerId, "DELETE"); + } + + private Long getMemberId(Principal principal) { + if (principal == null) { + throw new ErrorException(UNAUTHORIZED_MEMBER); + } + return Long.valueOf(principal.getName()); + } + } diff --git a/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java b/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java index b3059ac..a7d3146 100644 --- a/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java +++ b/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java @@ -1,8 +1,11 @@ package com.oronaminc.join.answer.api; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; @@ -12,6 +15,8 @@ import com.oronaminc.join.participant.dao.ParticipantRepository; import com.oronaminc.join.participant.domain.Participant; import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.participant.service.ParticipantReader; +import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; @@ -30,70 +35,129 @@ public class PermissionValidTests { @InjectMocks private PermissionValidator permissionValidator; - @Mock - private ParticipantRepository participantRepository; + private ParticipantReader participantReader; + @Mock - private MemberRepository memberRepository; - @Mock private RoomRepository roomRepository; + private AnswerReader answerReader; - private Member mockMember; - private Room mockRoom; + private Member questionAuthor; + private Room room; + private Member member; + private Question question; + private Answer answer; + private Participant participant; @BeforeEach void setUp() { - mockMember = Member.builder() - .id(1L) - .email("user@email.com") - .nickname("유저") - .memberType(MemberType.MEMBER) - .build(); - mockRoom = Room.builder() + member = Member.builder().id(1L).build(); + room = Room.builder().id(1L).build(); + questionAuthor = Member.builder().id(2L).build(); // 질문 작성자는 다른 사람으로 기본 설정 + participant = Participant.builder() .id(1L) - .title("제목") - .description("내용") - .secretCode("123456") - .emojiCount(0L) - .participantLimit(0) - .endedAt(LocalDateTime.now()) - .version(1) - .roomStatus(RoomStatus.STARTED) + .member(member) + .room(room) + .participantType(ParticipantType.GUEST) .build(); - + question = Question.builder().id(100L).member(questionAuthor).room(room).build(); + answer = Answer.builder().id(200L).question(question).member(member).build(); } @Test @DisplayName("TEAM or PRESENTER가 아닌 GUEST가 답변시 예외 발생") void validateAnswerPermission_fail_not_team_or_presenter() { - //given - Participant participant = Participant.builder() - .id(1L) - .member(mockMember) - .room(mockRoom) - .participantType(ParticipantType.GUEST) - .build(); - - given(participantRepository.findByRoomIdAndMemberId(1L, 1L)).willReturn(Optional.of(participant)); + // given + given(participantReader.getByRoomIdAndMemberId(1L, 1L)).willReturn(participant); // when & then assertThatThrownBy(() -> permissionValidator.validateAnswerPermission(1L, 1L)) .isInstanceOf(ErrorException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.UNAUTHORIZED_ROLE_ANSWER); - } @Test @DisplayName("발표방에 존재하지 않는 participant라면 예외 발생") void validateAnswerPermission_fail_not_found_participant() { - //given - given(participantRepository.findByRoomIdAndMemberId(1L, 1L)).willReturn(Optional.empty()); + // given + given(participantReader.getByRoomIdAndMemberId(1L, 1L)) + .willThrow(new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT)); // when & then assertThatThrownBy(() -> permissionValidator.validateAnswerPermission(1L, 1L)) .isInstanceOf(ErrorException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND_PARTICIPANT); + } + + @Test + @DisplayName("삭제 권한 - 질문 작성자(GUEST 포함)는 삭제 가능") + void deletePermission_success_byQuestionWriter() { + // given + question = Question.builder().id(100L).member(member).room(room).build(); // 질문자 = 본인 + answer = Answer.builder().id(200L).question(question).member(Member.builder().id(999L).build()).build(); // 답변자는 본인 아님 + Participant participant = Participant.builder() + .member(member) + .room(room) + .participantType(ParticipantType.GUEST) + .build(); + + given(answerReader.getById(200L)).willReturn(answer); + given(participantReader.getByRoomIdAndMemberId(1L, 1L)).willReturn(participant); + + // when & then + assertThatCode(() -> permissionValidator.validateAnswerDeletePermission( 200L, 1L)) + .doesNotThrowAnyException(); } + + @Test + @DisplayName("삭제 권한 - 팀원(TEAM)은 삭제 가능") + void deletePermission_success_byTeam() { + Participant participant = Participant.builder() + .member(member) + .room(room) + .participantType(ParticipantType.TEAM) + .build(); + + given(answerReader.getById(200L)).willReturn(answer); + given(participantReader.getByRoomIdAndMemberId(1L, 1L)).willReturn(participant); + + assertThatCode(() -> permissionValidator.validateAnswerDeletePermission( 200L, 1L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("삭제 권한 - 발표자(PRESENTER)는 삭제 가능") + void deletePermission_success_byPresenter() { + Participant participant = Participant.builder() + .member(member) + .room(room) + .participantType(ParticipantType.PRESENTER) + .build(); + + given(answerReader.getById(200L)).willReturn(answer); + given(participantReader.getByRoomIdAndMemberId(1L, 1L)).willReturn(participant); + + assertThatCode(() -> permissionValidator.validateAnswerDeletePermission( 200L, 1L)) + .doesNotThrowAnyException(); + } + + + @Test + @DisplayName("삭제 권한 - 권한 없는 GUEST는 삭제 불가") + void deletePermission_fail_unauthorizedGuest() { + Participant participant = Participant.builder() + .member(member) + .room(room) + .participantType(ParticipantType.GUEST) + .build(); + + given(answerReader.getById(200L)).willReturn(answer); + given(participantReader.getByRoomIdAndMemberId(1L, 1L)).willReturn(participant); + + assertThatThrownBy(() -> permissionValidator.validateAnswerDeletePermission(200L, 1L)) + .isInstanceOf(ErrorException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.UNAUTHORIZED_DELETE_ANSWER); + } } diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index c4a4830..8256e18 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -1,6 +1,7 @@ package com.oronaminc.join.answer.service; import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_ROOM; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -10,8 +11,9 @@ import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerCreateRequest; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.service.EmojiReader; @@ -22,9 +24,11 @@ import com.oronaminc.join.member.service.MemberReader; import com.oronaminc.join.participant.domain.Participant; import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.participant.service.ParticipantReader; import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; +import com.oronaminc.join.question.service.QuestionService; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; @@ -63,7 +67,7 @@ public class AnswerServiceTests { private Room mockRoom; private Question mockQuestion; private Participant mockParticipant; - private AnswerCreateRequest request; + private AnswerRequest request; private Emoji mockEmoji; @BeforeEach @@ -103,7 +107,7 @@ void setUp() { .participantType(ParticipantType.TEAM) .build(); - request = new AnswerCreateRequest("답변입니다."); + request = new AnswerRequest("답변입니다."); } @Test @@ -179,6 +183,28 @@ void getAnswer_success() { assertThat(response.writer().nickname()).isEqualTo(mockMember.getNickname()); } + @Test + @DisplayName("답변 수정 - 작성자 본인이면 수정에 성공한다") + void updateAnswer_success() { + // given + Answer answer = Answer.builder() + .id(1L) + .member(mockMember) + .question(mockQuestion) + .content("기존 내용") + .build(); + + given(answerReader.getById(1L)).willReturn(answer); + AnswerRequest request = new AnswerRequest("수정된 내용"); + + // when + Answer result = answerService.update(answer.getId(), request); + + // then + assertThat(result.getContent()).isEqualTo("수정된 내용"); + } + + @Test @DisplayName("존재하지 않는 member가 들어오면 예외 발생") void createAnswer_member_fail() { From de2ceec90d29226c6ec331060c3b4bfd272e3209 Mon Sep 17 00:00:00 2001 From: chcch529 <146617430+chcch529@users.noreply.github.com> Date: Thu, 17 Jul 2025 10:00:17 +0900 Subject: [PATCH 08/74] =?UTF-8?q?feat:=20=EC=A7=88=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20rate=20limiting=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 질문 dto validation 추가 * feat: 질문 rate limit 적용 * feat: bucket 조건 변경 * feat: enum type 이름 변경 * feat: valid 추가 --- .../join/global/exception/ErrorCode.java | 1 + .../join/global/ratelimit/RateLimitType.java | 7 ++++ .../join/question/domain/Question.java | 4 +-- .../question/dto/QuestionCreateRequest.java | 11 ------ .../join/question/dto/QuestionRequest.java | 15 ++++++++ .../question/service/QuestionService.java | 7 ++-- .../join/question/util/QuestionMapper.java | 4 +-- .../api/EmojiWebsocketController.java | 2 +- .../api/QuestionWebsocketController.java | 34 +++++++++++-------- .../service/QuestionServiceTests.java | 6 ++-- 10 files changed, 54 insertions(+), 37 deletions(-) delete mode 100644 src/main/java/com/oronaminc/join/question/dto/QuestionCreateRequest.java create mode 100644 src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 6449fbb..bd4260b 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -39,6 +39,7 @@ public enum ErrorCode { NOT_FOUND_QUESTION("QUESTION-002", "질문을 찾을 수 없습니다.", NOT_FOUND), UNAUTHORIZED_EDIT_QUESTION("QUESTION-003", "작성자만 질문을 수정할 수 있습니다.", UNAUTHORIZED), UNAUTHORIZED_DELETE_QUESTION("QUESTION-004", "작성자 및 관리자만 질문을 삭제할 수 있습니다.", UNAUTHORIZED), + TOO_MANY_REQUESTS_QUESTION("QUESTION-005", "잠시 후 다시 시도해주세요.", TOO_MANY_REQUESTS), UNAUTHORIZED_ROLE_ANSWER("ANSWER-001", "팀원 또는 발표자만 댓글을 작성할 수 있습니다.", UNAUTHORIZED), NOT_FOUND_EXIST_ANSWER("ANSWER-002", "해당 질문에 대한 답변이 존재하지 않습니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java index 4f860c7..2e23dec 100644 --- a/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java +++ b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java @@ -6,6 +6,13 @@ import lombok.Getter; public enum RateLimitType { + CREATE_QUESTION( + "CREATE_QUESTION:{}:{}", + Bandwidth.builder() + .capacity(3) + .refillIntervally(3, Duration.ofSeconds(15)) + .build() + ), EMOJI( "EMOJI:{}:{}:{}", Bandwidth.builder() diff --git a/src/main/java/com/oronaminc/join/question/domain/Question.java b/src/main/java/com/oronaminc/join/question/domain/Question.java index dc67139..d3d7d26 100644 --- a/src/main/java/com/oronaminc/join/question/domain/Question.java +++ b/src/main/java/com/oronaminc/join/question/domain/Question.java @@ -2,7 +2,7 @@ import com.oronaminc.join.global.entity.BaseEntity; import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.question.dto.QuestionCreateRequest; +import com.oronaminc.join.question.dto.QuestionRequest; import com.oronaminc.join.room.domain.Room; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -50,7 +50,7 @@ public class Question extends BaseEntity { @Version private Integer version; - public static Question create(Room room, Member member, QuestionCreateRequest requestDto) { + public static Question create(Room room, Member member, QuestionRequest requestDto) { return Question.builder() .room(room) .member(member) diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateRequest.java b/src/main/java/com/oronaminc/join/question/dto/QuestionCreateRequest.java deleted file mode 100644 index 973c3a1..0000000 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.oronaminc.join.question.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "질문 생성 요청 DTO") -public record QuestionCreateRequest( - @Schema(description = "질문 내용", example = "질문있습니다. 질문생성DTO가 맞나요?") - String content -) { - -} diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java b/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java new file mode 100644 index 0000000..c5c6772 --- /dev/null +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java @@ -0,0 +1,15 @@ +package com.oronaminc.join.question.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "질문 생성/수정 요청 DTO") +public record QuestionRequest( + @Schema(description = "질문 내용", example = "질문있습니다. 질문생성DTO가 맞나요?") + @NotBlank(message = "질문 내용을 입력해주시기 바랍니다.") + @Size(max = 500, message = "질문 내용은 최대 500자까지 입력할 수 있습니다.") + String content +) { + +} diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionService.java b/src/main/java/com/oronaminc/join/question/service/QuestionService.java index f49d768..3a99b8d 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionService.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionService.java @@ -12,7 +12,7 @@ import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.domain.QuestionSort; import com.oronaminc.join.question.dto.QuestionAssembleResponse; -import com.oronaminc.join.question.dto.QuestionCreateRequest; +import com.oronaminc.join.question.dto.QuestionRequest; import com.oronaminc.join.question.dto.QuestionFlatResponse; import com.oronaminc.join.question.util.QuestionMapper; import com.oronaminc.join.room.domain.Room; @@ -39,7 +39,7 @@ public class QuestionService { private final ParticipantReader participantReader; @Transactional - public Question create(Long roomId, Long memberId, QuestionCreateRequest requestDto) { + public Question create(Long roomId, Long memberId, QuestionRequest requestDto) { Member member = memberReader.getById(memberId); @@ -78,8 +78,7 @@ public Slice getQuestions( } @Transactional - public Question update(Long memberId, Long roomId, Long questionId, - QuestionCreateRequest request) { + public Question update(Long memberId, Long roomId, Long questionId, QuestionRequest request) { Question question = questionReader.getByIdAndRoomId(questionId, roomId); // 참여자가 아님 diff --git a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java index 2bbb9aa..9a784f0 100644 --- a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java +++ b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java @@ -4,7 +4,7 @@ import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; -import com.oronaminc.join.question.dto.QuestionCreateRequest; +import com.oronaminc.join.question.dto.QuestionRequest; import com.oronaminc.join.question.dto.QuestionCreateResponse; import com.oronaminc.join.question.dto.QuestionDeleteResponse; import com.oronaminc.join.question.dto.QuestionFlatResponse; @@ -19,7 +19,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class QuestionMapper { - public static Question toQuestion(Room room, Member member, QuestionCreateRequest request) { + public static Question toQuestion(Room room, Member member, QuestionRequest request) { return Question.create(room, member, request); } diff --git a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java index 30e1762..32716aa 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java @@ -47,7 +47,7 @@ public EmojiResponse createEmoji( @SendTo("/topic/rooms/{roomId}/emojis") public EmojiResponse deleteEmoji( @DestinationVariable Long roomId, - @Payload EmojiRequest emojiRequest, + @Payload @Valid EmojiRequest emojiRequest, Principal principal ) { Long memberId = Long.valueOf(principal.getName()); diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index a947a70..7186fb2 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -1,14 +1,18 @@ package com.oronaminc.join.websocket.api; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.oronaminc.join.member.security.MemberDetails; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.global.ratelimit.RateLimitService; +import com.oronaminc.join.global.ratelimit.RateLimitType; import com.oronaminc.join.question.domain.Question; -import com.oronaminc.join.question.dto.QuestionCreateRequest; import com.oronaminc.join.question.dto.QuestionCreateResponse; import com.oronaminc.join.question.dto.QuestionDeleteResponse; +import com.oronaminc.join.question.dto.QuestionRequest; import com.oronaminc.join.question.dto.QuestionUpdateResponse; -import com.oronaminc.join.question.util.QuestionMapper; import com.oronaminc.join.question.service.QuestionService; +import com.oronaminc.join.question.util.QuestionMapper; +import io.github.bucket4j.Bucket; +import jakarta.validation.Valid; import java.security.Principal; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,7 +20,6 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; @Slf4j @@ -25,18 +28,23 @@ public class QuestionWebsocketController { private final QuestionService questionService; - private final ObjectMapper objectMapper; + private final RateLimitService rateLimitService; @MessageMapping("/rooms/{roomId}/questions/create") @SendTo("/topic/rooms/{roomId}/questions") - public QuestionCreateResponse create( + public QuestionCreateResponse createQuestion( @DestinationVariable Long roomId, - @Payload QuestionCreateRequest request, + @Payload @Valid QuestionRequest request, Principal principal ) { - Long memberId = Long.valueOf(principal.getName()); + Bucket bucket = rateLimitService.getBucket(RateLimitType.CREATE_QUESTION, roomId, memberId); + + if (!bucket.tryConsume(1)) { + throw new ErrorException(ErrorCode.TOO_MANY_REQUESTS_QUESTION); + } + Question question = questionService.create(roomId, memberId, request); log.info("수신한 메시지 = {}", request.content()); @@ -46,13 +54,12 @@ public QuestionCreateResponse create( @MessageMapping("/rooms/{roomId}/questions/{questionId}/update") @SendTo("/topic/rooms/{roomId}/questions") - public QuestionUpdateResponse update( + public QuestionUpdateResponse updateQuestion( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - @Payload QuestionCreateRequest request, + @Payload @Valid QuestionRequest request, Principal principal ) { - Long memberId = Long.valueOf(principal.getName()); Question updated = questionService.update(memberId, roomId, questionId, request); @@ -62,12 +69,11 @@ public QuestionUpdateResponse update( @MessageMapping("rooms/{roomId}/questions/{questionId}/delete") @SendTo("/topic/rooms/{roomId}/questions") - public QuestionDeleteResponse delete( + public QuestionDeleteResponse deleteQuestion( @DestinationVariable Long roomId, @DestinationVariable Long questionId, Principal principal ) { - Long memberId = Long.valueOf(principal.getName()); Long deletedId = questionService.delete(memberId, roomId, questionId); diff --git a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java index a1617ff..d37c894 100644 --- a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java +++ b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java @@ -21,8 +21,8 @@ import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.domain.QuestionSort; import com.oronaminc.join.question.dto.QuestionAssembleResponse; -import com.oronaminc.join.question.dto.QuestionCreateRequest; import com.oronaminc.join.question.dto.QuestionFlatResponse; +import com.oronaminc.join.question.dto.QuestionRequest; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; @@ -66,7 +66,7 @@ class QuestionServiceTests { private Room mockRoom; private Member mockMember; - private QuestionCreateRequest request; + private QuestionRequest request; private QuestionFlatResponse mockQ1; private QuestionFlatResponse mockQ2; @@ -93,7 +93,7 @@ void setUp() { .roomStatus(RoomStatus.STARTED) .build(); - request = new QuestionCreateRequest("질문입니다"); + request = new QuestionRequest("질문입니다"); mockQ1 = QuestionFlatResponse.builder() .questionId(1L) From 6018ddf87d976ff5e310a3bd7aea38bd177099ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:55:50 +0900 Subject: [PATCH 09/74] =?UTF-8?q?feat:=20=EC=B0=B8=EA=B0=80=EC=9E=90=20?= =?UTF-8?q?=ED=87=B4=EC=9E=A5=20=EC=8B=9C=EA=B0=84=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9B=B9=EC=86=8C=EC=BC=93,=20=EB=B0=9C=ED=91=9C?= =?UTF-8?q?=EB=B0=A9=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 발표방 관련 스웨거 설명 추가, 비밀코드 방 참가 조건 수정 * refactor: 발표방 리팩토링 * refactor: 회원, 인증 리팩토링 * refactor: websocket 패키지 구조 변경 * feat: 방 삭제 이벤트로 수정 * feat: 참가자 퇴장 시간 기록 구현 --- .../document/service/DocumentService.java | 8 +-- .../oronaminc/join/member/domain/Member.java | 4 ++ .../join/member/security/AuthService.java | 41 +++------------ .../join/member/util/MemberMapper.java | 50 +++++++++++++++++++ .../join/participant/domain/Participant.java | 22 ++++++-- .../event/ParticipantEventHandler.java | 21 ++++++++ .../service/ParticipantService.java | 15 +++++- .../join/question/dao/QuestionRepository.java | 1 - .../join/question/service/QuestionReader.java | 14 +++--- .../question/service/QuestionService.java | 16 +++--- .../join/room/api/RoomController.java | 47 ++++++++++++++++- .../join/room/dto/ReportResponse.java | 6 +-- .../room/dto/RoomUpdateStatusRequest.java | 3 ++ .../{TopQnADto.java => TopQnAResponse.java} | 2 +- .../join/room/event/RoomDeleteEvent.java | 6 +++ .../join/room/event/RoomEventHandler.java | 28 +++++++++++ .../room/{dto => event}/RoomExitEvent.java | 2 +- .../join/room/service/RoomReader.java | 2 +- .../join/room/service/RoomService.java | 49 +++++++++--------- .../oronaminc/join/room/util/RoomMapper.java | 4 +- .../api/RoomWebsocketController.java | 2 +- .../api/WebSocketExceptionHandler.java | 2 +- .../websocket/config/WebSocketConfig.java | 5 ++ .../CustomHandshakeHandler.java | 22 ++++---- .../CurrentParticipantEventHandler.java} | 12 ++--- .../CurrentParticipantManager.java} | 4 +- .../CustomWebSocketHandlerDecorator.java | 4 +- .../WebsocketSessionManager.java | 2 +- .../{config => stomp}/StompErrorHandler.java | 19 ++++--- .../{config => stomp}/StompPrincipal.java | 2 +- 30 files changed, 291 insertions(+), 124 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/member/util/MemberMapper.java create mode 100644 src/main/java/com/oronaminc/join/participant/event/ParticipantEventHandler.java rename src/main/java/com/oronaminc/join/room/dto/{TopQnADto.java => TopQnAResponse.java} (83%) create mode 100644 src/main/java/com/oronaminc/join/room/event/RoomDeleteEvent.java create mode 100644 src/main/java/com/oronaminc/join/room/event/RoomEventHandler.java rename src/main/java/com/oronaminc/join/room/{dto => event}/RoomExitEvent.java (66%) rename src/main/java/com/oronaminc/join/websocket/{config => handshake}/CustomHandshakeHandler.java (87%) rename src/main/java/com/oronaminc/join/websocket/{config/ParticipantEventHandler.java => session/CurrentParticipantEventHandler.java} (85%) rename src/main/java/com/oronaminc/join/websocket/{config/ParticipantManager.java => session/CurrentParticipantManager.java} (93%) rename src/main/java/com/oronaminc/join/websocket/{config => session}/CustomWebSocketHandlerDecorator.java (95%) rename src/main/java/com/oronaminc/join/websocket/{config => session}/WebsocketSessionManager.java (95%) rename src/main/java/com/oronaminc/join/websocket/{config => stomp}/StompErrorHandler.java (98%) rename src/main/java/com/oronaminc/join/websocket/{config => stomp}/StompPrincipal.java (84%) diff --git a/src/main/java/com/oronaminc/join/document/service/DocumentService.java b/src/main/java/com/oronaminc/join/document/service/DocumentService.java index 37f889a..e50e472 100644 --- a/src/main/java/com/oronaminc/join/document/service/DocumentService.java +++ b/src/main/java/com/oronaminc/join/document/service/DocumentService.java @@ -1,6 +1,9 @@ package com.oronaminc.join.document.service; +import java.util.UUID; + import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.oronaminc.join.document.dao.DocumentRepository; import com.oronaminc.join.document.dto.DocumentRequest; @@ -11,13 +14,9 @@ import com.oronaminc.join.infra.service.S3Service; import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.room.domain.Room; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import java.util.UUID; - - @Service @RequiredArgsConstructor public class DocumentService { @@ -25,6 +24,7 @@ public class DocumentService { private final DocumentRepository documentRepository; private final S3Service s3Service; + @Transactional public void deleteByRoomId(Long roomId) { documentRepository.deleteByRoomId(roomId); } diff --git a/src/main/java/com/oronaminc/join/member/domain/Member.java b/src/main/java/com/oronaminc/join/member/domain/Member.java index 837948b..c7254b7 100644 --- a/src/main/java/com/oronaminc/join/member/domain/Member.java +++ b/src/main/java/com/oronaminc/join/member/domain/Member.java @@ -30,4 +30,8 @@ public class Member extends BaseEntity { public void updateNickname(String nickname) { this.nickname = nickname; } + + public void registerGuest() { + this.email = "GUEST_" + this.id; + } } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthService.java b/src/main/java/com/oronaminc/join/member/security/AuthService.java index f9c9c72..ba1c2ad 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthService.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthService.java @@ -1,5 +1,7 @@ package com.oronaminc.join.member.security; +import static com.oronaminc.join.member.util.MemberMapper.*; + import java.util.Map; import java.util.Optional; @@ -12,7 +14,6 @@ import com.oronaminc.join.member.dao.MemberRepository; import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.dto.GuestLoginRequest; import com.oronaminc.join.member.service.MemberReader; @@ -39,45 +40,19 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic Optional optionalMember = memberReader.findByEmail(kakaoAccount.get("email").toString()); - Member member = optionalMember.orElseGet( - () -> memberRepository.save( - Member.builder() - .email(kakaoAccount.get("email").toString()) - .nickname(profile.get("nickname").toString()) - .profileImage(profile.get("profile_image_url").toString()) - .memberType(MemberType.MEMBER) - .build() - ) - ); - - return MemberDetails.builder() - .id(member.getId()) - .name(member.getEmail()) - .nickname(member.getNickname()) - .role(member.getMemberType()) - .build(); + Member member = optionalMember.orElseGet(() -> memberRepository.save(toKakaoMember(kakaoAccount, profile))); + + return toOAuth2MemberDetails(member); } @Transactional public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { - Member guest = Member.builder() - .email(null) - .nickname(guestLoginRequest.nickname()) - .profileImage(null) - .memberType(MemberType.GUEST) - .build(); + Member guest = toGuestMember(guestLoginRequest); memberRepository.save(guest); + guest.registerGuest(); - // 1. 비회원 MemberDetails 생성 - MemberDetails memberDetails = MemberDetails.builder() - .id(guest.getId()) - .name("GUEST_" + guest.getId()) - .nickname(guest.getNickname()) - .role(MemberType.GUEST) - .build(); - - return memberDetails; + return toGuestMemberDetails(guest); } } diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java new file mode 100644 index 0000000..673fa6d --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -0,0 +1,50 @@ +package com.oronaminc.join.member.util; + +import java.util.Map; + +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.member.domain.MemberType; +import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.security.MemberDetails; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberMapper { + public static MemberDetails toOAuth2MemberDetails(Member member) { + return MemberDetails.builder() + .id(member.getId()) + .name(member.getEmail()) + .nickname(member.getNickname()) + .role(member.getMemberType()) + .build(); + } + + public static MemberDetails toGuestMemberDetails(Member guest) { + return MemberDetails.builder() + .id(guest.getId()) + .name(guest.getEmail()) + .nickname(guest.getNickname()) + .role(MemberType.GUEST) + .build(); + } + + public static Member toGuestMember(GuestLoginRequest guestLoginRequest) { + return Member.builder() + .email(null) + .nickname(guestLoginRequest.nickname()) + .profileImage(null) + .memberType(MemberType.GUEST) + .build(); + } + + public static Member toKakaoMember(Map kakaoAccount, Map profile) { + return Member.builder() + .email(kakaoAccount.get("email").toString()) + .nickname(profile.get("nickname").toString()) + .profileImage(profile.get("profile_image_url").toString()) + .memberType(MemberType.MEMBER) + .build(); + } +} diff --git a/src/main/java/com/oronaminc/join/participant/domain/Participant.java b/src/main/java/com/oronaminc/join/participant/domain/Participant.java index 25783a4..b2ca28b 100644 --- a/src/main/java/com/oronaminc/join/participant/domain/Participant.java +++ b/src/main/java/com/oronaminc/join/participant/domain/Participant.java @@ -1,15 +1,23 @@ package com.oronaminc.join.participant.domain; -import jakarta.persistence.Column; -import jakarta.persistence.FetchType; -import jakarta.persistence.Index; -import jakarta.persistence.Table; import java.time.LocalDateTime; import com.oronaminc.join.global.entity.BaseEntity; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.room.domain.Room; -import jakarta.persistence.*; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -44,4 +52,8 @@ public class Participant extends BaseEntity { private ParticipantType participantType; private LocalDateTime exitedAt; + + public void updateExitAt() { + this.exitedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/oronaminc/join/participant/event/ParticipantEventHandler.java b/src/main/java/com/oronaminc/join/participant/event/ParticipantEventHandler.java new file mode 100644 index 0000000..a5a1cb3 --- /dev/null +++ b/src/main/java/com/oronaminc/join/participant/event/ParticipantEventHandler.java @@ -0,0 +1,21 @@ +package com.oronaminc.join.participant.event; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import com.oronaminc.join.participant.service.ParticipantService; +import com.oronaminc.join.room.event.RoomExitEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ParticipantEventHandler { + + private final ParticipantService participantService; + + @EventListener + public void exitRoomEvent(RoomExitEvent roomExitEvent) { + participantService.updateExitAt(roomExitEvent.roomId(), roomExitEvent.memberId()); + } +} diff --git a/src/main/java/com/oronaminc/join/participant/service/ParticipantService.java b/src/main/java/com/oronaminc/join/participant/service/ParticipantService.java index d1f0957..44a0cd8 100644 --- a/src/main/java/com/oronaminc/join/participant/service/ParticipantService.java +++ b/src/main/java/com/oronaminc/join/participant/service/ParticipantService.java @@ -20,13 +20,14 @@ import lombok.RequiredArgsConstructor; @Service -@Transactional +@Transactional(readOnly=true) @RequiredArgsConstructor public class ParticipantService { private final ParticipantRepository participantRepository; private final ParticipantReader participantReader; private final MemberReader memberReader; + @Transactional public void savePresenterAndTeam(String presenterEmail, List teamEmail, Room room) { saveMemberParticipantByEmail(presenterEmail, room, ParticipantType.PRESENTER); for (String email : teamEmail) { @@ -34,6 +35,7 @@ public void savePresenterAndTeam(String presenterEmail, List teamEmail, } } + @Transactional public void saveMemberParticipantByEmail(String email, Room room, ParticipantType participantType) { Member participantMember = memberReader.getByEmail(email); if (participantMember.getMemberType().equals(MemberType.GUEST)) { @@ -42,7 +44,8 @@ public void saveMemberParticipantByEmail(String email, Room room, ParticipantTyp Participant participant = ParticipantMapper.toParticipant(participantMember, room, participantType); participantRepository.save(participant); } - + + @Transactional public void saveParticipantById(Long memberId, Room room, ParticipantType participantType) { Member participantMember = memberReader.getById(memberId); if (participantReader.existsByRoomIdAndMemberId(room.getId(), participantMember.getId())) { @@ -67,6 +70,7 @@ public List getTeam(Long roomId) { return participantReader.findAllByRoomIdAndParticipantType(roomId, ParticipantType.TEAM); } + @Transactional public void updateTeam(Room room, List emails) { List team = this.getTeam(room.getId()); for (Participant participant : team) { @@ -87,7 +91,14 @@ public void validatePresenter(Long roomId, Long memberId) { } } + @Transactional public void deleteParticipantByRoomId(Long roomId) { participantRepository.deleteByRoomId(roomId); } + + @Transactional + public void updateExitAt(Long roomId, Long memberId) { + Participant participant = participantReader.getByRoomIdAndMemberId(roomId, memberId); + participant.updateExitAt(); + } } diff --git a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java index b8e3eaa..957dd33 100644 --- a/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java +++ b/src/main/java/com/oronaminc/join/question/dao/QuestionRepository.java @@ -4,7 +4,6 @@ import java.util.Optional; import java.util.List; -import com.oronaminc.join.room.dto.TopQnADto; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java index 8850ff3..3c738f0 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionReader.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionReader.java @@ -1,18 +1,20 @@ package com.oronaminc.join.question.service; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.question.dao.QuestionRepository; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.domain.QuestionSort; import com.oronaminc.join.question.dto.QuestionFlatResponse; -import com.oronaminc.join.room.dto.TopQnADto; -import java.util.List; -import java.util.Optional; + import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionService.java b/src/main/java/com/oronaminc/join/question/service/QuestionService.java index 3a99b8d..c12597e 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionService.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionService.java @@ -1,5 +1,13 @@ package com.oronaminc.join.question.service; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; @@ -17,13 +25,8 @@ import com.oronaminc.join.question.util.QuestionMapper; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; -import java.util.List; + import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -117,7 +120,6 @@ public Long delete(Long memberId, Long roomId, Long questionId) { return question.getId(); } - @Transactional public void deleteByRoomId(Long roomId) { List questions = questionReader.findByRoomId(roomId); diff --git a/src/main/java/com/oronaminc/join/room/api/RoomController.java b/src/main/java/com/oronaminc/join/room/api/RoomController.java index 61e4b02..ff58993 100644 --- a/src/main/java/com/oronaminc/join/room/api/RoomController.java +++ b/src/main/java/com/oronaminc/join/room/api/RoomController.java @@ -1,6 +1,5 @@ package com.oronaminc.join.room.api; -import com.oronaminc.join.room.dto.*; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -14,14 +13,25 @@ import org.springframework.web.bind.annotation.RestController; import com.oronaminc.join.member.security.MemberDetails; +import com.oronaminc.join.room.dto.CreateRoomRequest; +import com.oronaminc.join.room.dto.CreateRoomResponse; +import com.oronaminc.join.room.dto.JoinRoomRequest; +import com.oronaminc.join.room.dto.JoinRoomResponse; +import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.dto.RoomDetailResponse; +import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; +import com.oronaminc.join.room.dto.RoomUpdateRequest; +import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; import com.oronaminc.join.room.service.RoomService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +@Tag(name = "발표방") @RestController @RequestMapping("/api/rooms") @RequiredArgsConstructor @@ -48,6 +58,11 @@ public CreateRoomResponse createRoom( return roomService.createRoom(createRoomRequest, presenterEmail); } + @Operation( + summary = "비밀코드로 방 입장", + description = "비밀코드를 통해 해당 발표방에 참가자로 등록합니다. 시작 전 상태이면 참가할 수 없습니다.", + security = @SecurityRequirement(name = "sessionAuth") + ) @GetMapping("/code") @ResponseStatus(HttpStatus.OK) public JoinRoomResponse joinRoom( @@ -57,12 +72,22 @@ public JoinRoomResponse joinRoom( return roomService.joinRoom(memberDetails.getId(), joinRoomRequest); } + @Operation( + summary = "발표방 상세 조회", + description = "발표방을 상세 조회합니다. 비밀코드로 방 입장을 통해 참가자로 등록되었거나 팀 혹은 생성자가 아니면 예외가 발생합니다. roomStatus에는 BEFORE_START, STARTED, ENDED가 있습니다.", + security = @SecurityRequirement(name = "sessionAuth") + ) @GetMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) public RoomDetailResponse getRoomDetail(@PathVariable Long roomId, @AuthenticationPrincipal MemberDetails memberDetails) { return roomService.getRoomDetail(memberDetails.getId(), roomId); } + @Operation( + summary = "발표방 수정", + description = "발표방을 수정합니다. 발표방 생성자만 가능합니다. 기존 값을 유지하고 싶으면 발표방 수정용 조회를 통해 가져온 값을 그대로 입력해주세요.", + security = @SecurityRequirement(name = "sessionAuth") + ) @PatchMapping("/{roomId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void updateRoom( @@ -73,12 +98,22 @@ public void updateRoom( roomService.updateRoom(memberDetails.getId(), roomId, roomUpdateRequest); } + @Operation( + summary = "발표방 삭제", + description = "발표방을 삭제합니다. 발표방 생성자만 가능합니다.", + security = @SecurityRequirement(name = "sessionAuth") + ) @DeleteMapping("/{roomId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteRoom(@PathVariable Long roomId, @AuthenticationPrincipal MemberDetails memberDetails) { roomService.deleteRoom(memberDetails.getId(), roomId); } + @Operation( + summary = "발표방 상태변경", + description = "발표방의 상태를 변경합니다.", + security = @SecurityRequirement(name = "sessionAuth") + ) @PatchMapping("/{roomId}/status") @ResponseStatus(HttpStatus.NO_CONTENT) public void updateRoomStatus( @@ -88,6 +123,11 @@ public void updateRoomStatus( roomService.updateRoomStatus(memberDetails.getId(), roomId, roomUpdateStatusRequest); } + @Operation( + summary = "발표방 수정용 조회", + description = "발표방 수정용 조회입니다. 발표방 수정에서 이 값을 그대로 보내주시면 수정되지 않습니다.", + security = @SecurityRequirement(name = "sessionAuth") + ) @GetMapping("/{roomId}/update") @ResponseStatus(HttpStatus.OK) public RoomUpdateInfoResponse getUpdateInfo( @@ -97,6 +137,11 @@ public RoomUpdateInfoResponse getUpdateInfo( return roomService.getRoomUpdateInfo(memberDetails.getId(), roomId); } + @Operation( + summary = "리포트 조회", + description = "리포트에 필요한 데이터 조회입니다. 생성자만 조회 가능합니다.", + security = @SecurityRequirement(name = "sessionAuth") + ) @GetMapping("/{roomId}/report") @ResponseStatus(HttpStatus.OK) public ReportResponse getReport( diff --git a/src/main/java/com/oronaminc/join/room/dto/ReportResponse.java b/src/main/java/com/oronaminc/join/room/dto/ReportResponse.java index d14d18e..0503474 100644 --- a/src/main/java/com/oronaminc/join/room/dto/ReportResponse.java +++ b/src/main/java/com/oronaminc/join/room/dto/ReportResponse.java @@ -1,9 +1,9 @@ package com.oronaminc.join.room.dto; -import lombok.Builder; - import java.util.List; +import lombok.Builder; + @Builder public record ReportResponse( Long roomId, @@ -12,6 +12,6 @@ public record ReportResponse( Long totalQuestions, Double answerRate, Long totalEmojis, - List topQnA + List topQnA ) { } diff --git a/src/main/java/com/oronaminc/join/room/dto/RoomUpdateStatusRequest.java b/src/main/java/com/oronaminc/join/room/dto/RoomUpdateStatusRequest.java index ec0348f..8c960b7 100644 --- a/src/main/java/com/oronaminc/join/room/dto/RoomUpdateStatusRequest.java +++ b/src/main/java/com/oronaminc/join/room/dto/RoomUpdateStatusRequest.java @@ -2,7 +2,10 @@ import com.oronaminc.join.room.domain.RoomStatus; +import io.swagger.v3.oas.annotations.media.Schema; + public record RoomUpdateStatusRequest( + @Schema(description = "BEFORE_START, STARTED, ENDED", example = "BEFORE_START") RoomStatus roomStatus ) { } diff --git a/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java b/src/main/java/com/oronaminc/join/room/dto/TopQnAResponse.java similarity index 83% rename from src/main/java/com/oronaminc/join/room/dto/TopQnADto.java rename to src/main/java/com/oronaminc/join/room/dto/TopQnAResponse.java index 7e76bc3..5645aad 100644 --- a/src/main/java/com/oronaminc/join/room/dto/TopQnADto.java +++ b/src/main/java/com/oronaminc/join/room/dto/TopQnAResponse.java @@ -2,7 +2,7 @@ import java.util.List; -public record TopQnADto( +public record TopQnAResponse( String question, Long emojiCount, List answers diff --git a/src/main/java/com/oronaminc/join/room/event/RoomDeleteEvent.java b/src/main/java/com/oronaminc/join/room/event/RoomDeleteEvent.java new file mode 100644 index 0000000..025de9d --- /dev/null +++ b/src/main/java/com/oronaminc/join/room/event/RoomDeleteEvent.java @@ -0,0 +1,6 @@ +package com.oronaminc.join.room.event; + +public record RoomDeleteEvent( + Long roomId +) { +} diff --git a/src/main/java/com/oronaminc/join/room/event/RoomEventHandler.java b/src/main/java/com/oronaminc/join/room/event/RoomEventHandler.java new file mode 100644 index 0000000..f032491 --- /dev/null +++ b/src/main/java/com/oronaminc/join/room/event/RoomEventHandler.java @@ -0,0 +1,28 @@ +package com.oronaminc.join.room.event; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import com.oronaminc.join.document.service.DocumentService; +import com.oronaminc.join.emoji.service.EmojiService; +import com.oronaminc.join.participant.service.ParticipantService; +import com.oronaminc.join.question.service.QuestionService; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class RoomEventHandler { + private final ParticipantService participantService; + private final QuestionService questionService; + private final EmojiService emojiService; + private final DocumentService documentService; + + @EventListener + public void handleRoomDelete(RoomDeleteEvent event) { + participantService.deleteParticipantByRoomId(event.roomId()); + questionService.deleteByRoomId(event.roomId()); + emojiService.deleteByRoomEmoji(event.roomId()); + documentService.deleteByRoomId(event.roomId()); + } +} diff --git a/src/main/java/com/oronaminc/join/room/dto/RoomExitEvent.java b/src/main/java/com/oronaminc/join/room/event/RoomExitEvent.java similarity index 66% rename from src/main/java/com/oronaminc/join/room/dto/RoomExitEvent.java rename to src/main/java/com/oronaminc/join/room/event/RoomExitEvent.java index e70cf30..d7eac04 100644 --- a/src/main/java/com/oronaminc/join/room/dto/RoomExitEvent.java +++ b/src/main/java/com/oronaminc/join/room/event/RoomExitEvent.java @@ -1,4 +1,4 @@ -package com.oronaminc.join.room.dto; +package com.oronaminc.join.room.event; public record RoomExitEvent( Long memberId, diff --git a/src/main/java/com/oronaminc/join/room/service/RoomReader.java b/src/main/java/com/oronaminc/join/room/service/RoomReader.java index 80ea9ce..e0fef8f 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomReader.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomReader.java @@ -22,7 +22,7 @@ public Optional findById(Long roomId) { } public Room getById(Long roomId) { - return roomRepository.findById(roomId) + return findById(roomId) .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index f627f89..9502d8f 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,7 +15,6 @@ import com.oronaminc.join.document.domain.Document; import com.oronaminc.join.document.service.DocumentReader; import com.oronaminc.join.document.service.DocumentService; -import com.oronaminc.join.emoji.service.EmojiService; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.infra.service.S3Service; import com.oronaminc.join.participant.domain.Participant; @@ -23,7 +23,6 @@ import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.question.service.QuestionService; import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; @@ -32,38 +31,39 @@ import com.oronaminc.join.room.dto.JoinRoomRequest; import com.oronaminc.join.room.dto.JoinRoomResponse; import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.event.RoomDeleteEvent; import com.oronaminc.join.room.dto.RoomDetailResponse; import com.oronaminc.join.room.dto.RoomJoinResponse; import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; import com.oronaminc.join.room.dto.RoomUpdateRequest; import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; -import com.oronaminc.join.room.dto.TopQnADto; +import com.oronaminc.join.room.dto.TopQnAResponse; import com.oronaminc.join.room.util.CodeGenerator; import com.oronaminc.join.room.util.RoomMapper; -import com.oronaminc.join.websocket.config.ParticipantManager; +import com.oronaminc.join.websocket.session.CurrentParticipantManager; import lombok.RequiredArgsConstructor; @Service -@Transactional +@Transactional(readOnly = true) @RequiredArgsConstructor public class RoomService { private final RoomRepository roomRepository; private final ParticipantService participantService; private final DocumentService documentService; - private final QuestionService questionService; - private final DocumentReader documentReader; - private final EmojiService emojiService; private final S3Service s3Service; - private final RoomReader roomReader; - private final AnswerReader answerReader; private final ParticipantReader participantReader; + private final DocumentReader documentReader; private final QuestionReader questionReader; - private final ParticipantManager participantManager; + private final AnswerReader answerReader; + private final RoomReader roomReader; + private final CurrentParticipantManager currentParticipantManager; + private final ApplicationEventPublisher publisher; private static final int CODE_LENGTH = 6; + @Transactional public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, String presenterEmail) { String code = this.generateCode(); @@ -76,9 +76,10 @@ public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, return RoomMapper.toCreateRoomResponse(room); } + @Transactional public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) { Room room = roomReader.getBySecretCode(joinRoomRequest.secretCode()); - if (room.getRoomStatus().equals(RoomStatus.STARTED)) { + if (room.getRoomStatus().equals(RoomStatus.BEFORE_START)) { throw new ErrorException(UNAUTHORIZED_JOIN_ROOM); } participantService.saveParticipantById(memberId, room, ParticipantType.GUEST); @@ -93,12 +94,13 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { Participant presenter = participantService.getPresenter(roomId); List team = participantService.getTeam(roomId); Document document = documentReader.getByRoomId(roomId); - int participantCount = participantManager.getRoomParticipants(roomId).size(); + int participantCount = currentParticipantManager.getRoomParticipants(roomId).size(); String presignedUrl = s3Service.generatePresignedUrl(document.getFileUrl()); return RoomMapper.toRoomDetailResponse(room, presenter, team, presignedUrl, memberId, participantCount); } + @Transactional public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomRequest) { participantService.validatePresenter(roomId, memberId); @@ -114,6 +116,7 @@ public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomR participantService.updateTeam(room, updateRoomRequest.teamEmail()); } + @Transactional public void deleteRoom(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); @@ -124,15 +127,12 @@ public void deleteRoom(Long memberId, Long roomId) { throw new ErrorException(BAD_REQUEST_ROOM_STARTED); } - participantService.deleteParticipantByRoomId(roomId); - questionService.deleteByRoomId(roomId); - emojiService.deleteByRoomEmoji(roomId); - // S3 버킷 내 파일 삭제 - s3Service.deleteFile(document.getFileUrl()); - documentService.deleteByRoomId(roomId); + publisher.publishEvent(new RoomDeleteEvent(roomId)); roomRepository.deleteById(roomId); + s3Service.deleteFile(document.getFileUrl()); } + @Transactional public void updateRoomStatus(Long memberId, Long roomId, RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); @@ -146,6 +146,7 @@ public void updateRoomStatus(Long memberId, Long roomId, room.updateStatus(updateStatus); } + @Transactional public RoomUpdateInfoResponse getRoomUpdateInfo(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); @@ -175,12 +176,12 @@ public ReportResponse getRoomReport(Long roomId, Long memberId) { Long totalQuestions = questionReader.countByRoomId(roomId); Long totalAnswerByQuestion = answerReader.countAnsweredQuestionsByRoomId(roomId); Double answerRate = calculateAnswerRate(totalQuestions, totalAnswerByQuestion); - List topQnA = getTopQnA(roomId); + List topQnA = getTopQnA(roomId); return RoomMapper.toReportResponse(room, totalView, totalQuestions, answerRate, topQnA); } - private List getTopQnA(Long roomId) { + private List getTopQnA(Long roomId) { // top3 질문 리스트 List top3Question = questionReader.findTop3Question(roomId); @@ -200,7 +201,7 @@ private List getTopQnA(Long roomId) { )); return top3Question.stream() - .map(q -> new TopQnADto( + .map(q -> new TopQnAResponse( q.getContent(), q.getEmojiCount(), answersByQuestionId.getOrDefault(q.getId(), List.of()) @@ -222,7 +223,7 @@ public RoomJoinResponse subscribeRoom(Long roomId, Long memberId) { throw new ErrorException(UNAUTHORIZED_SUBSCRIBE_ROOM); } Integer limit = room.getParticipantLimit(); - participantManager.addParticipant(roomId, memberId, limit); - return new RoomJoinResponse(participantManager.getRoomParticipants(roomId).size()); + currentParticipantManager.addParticipant(roomId, memberId, limit); + return new RoomJoinResponse(currentParticipantManager.getRoomParticipants(roomId).size()); } } diff --git a/src/main/java/com/oronaminc/join/room/util/RoomMapper.java b/src/main/java/com/oronaminc/join/room/util/RoomMapper.java index c0e7b76..eb24a38 100644 --- a/src/main/java/com/oronaminc/join/room/util/RoomMapper.java +++ b/src/main/java/com/oronaminc/join/room/util/RoomMapper.java @@ -12,7 +12,7 @@ import com.oronaminc.join.room.dto.ReportResponse; import com.oronaminc.join.room.dto.RoomDetailResponse; import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; -import com.oronaminc.join.room.dto.TopQnADto; +import com.oronaminc.join.room.dto.TopQnAResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -68,7 +68,7 @@ public static RoomUpdateInfoResponse toRoomUpdateInfoResponse(Room room, List top3QnA) { + public static ReportResponse toReportResponse(Room room, Long totalView,Long totalQuestions, Double answerRate, List top3QnA) { return ReportResponse.builder() .roomId(room.getId()) .title(room.getTitle()) diff --git a/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java index 4a4f3c1..a6221de 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java @@ -11,7 +11,7 @@ import com.oronaminc.join.room.dto.RoomJoinResponse; import com.oronaminc.join.room.service.RoomService; -import com.oronaminc.join.websocket.config.WebsocketSessionManager; +import com.oronaminc.join.websocket.session.WebsocketSessionManager; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java b/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java index a926212..ad738d1 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/api/WebSocketExceptionHandler.java @@ -13,7 +13,7 @@ import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.exception.ErrorResponse; -import com.oronaminc.join.websocket.config.WebsocketSessionManager; +import com.oronaminc.join.websocket.session.WebsocketSessionManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index f423a25..a2afca6 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -11,6 +11,11 @@ import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; +import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; +import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; +import com.oronaminc.join.websocket.session.WebsocketSessionManager; +import com.oronaminc.join.websocket.stomp.StompErrorHandler; + import lombok.RequiredArgsConstructor; @Configuration diff --git a/src/main/java/com/oronaminc/join/websocket/config/CustomHandshakeHandler.java b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java similarity index 87% rename from src/main/java/com/oronaminc/join/websocket/config/CustomHandshakeHandler.java rename to src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java index a14fbfa..2b5b78a 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/CustomHandshakeHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java @@ -1,25 +1,25 @@ -package com.oronaminc.join.websocket.config; +package com.oronaminc.join.websocket.handshake; -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.security.MemberDetails; -import com.oronaminc.join.member.service.MemberReader; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; import java.security.Principal; import java.util.Map; -import lombok.RequiredArgsConstructor; + import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.support.DefaultHandshakeHandler; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.member.security.MemberDetails; +import com.oronaminc.join.websocket.stomp.StompPrincipal; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class CustomHandshakeHandler extends DefaultHandshakeHandler { diff --git a/src/main/java/com/oronaminc/join/websocket/config/ParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java similarity index 85% rename from src/main/java/com/oronaminc/join/websocket/config/ParticipantEventHandler.java rename to src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c798c5e..c98a2ce 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/ParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -1,4 +1,4 @@ -package com.oronaminc.join.websocket.config; +package com.oronaminc.join.websocket.session; import static com.oronaminc.join.global.exception.ErrorCode.*; @@ -11,14 +11,14 @@ import org.springframework.web.socket.messaging.SessionSubscribeEvent; import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.room.dto.RoomExitEvent; +import com.oronaminc.join.room.event.RoomExitEvent; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor -public class ParticipantEventHandler { - private final ParticipantManager participantManager; +public class CurrentParticipantEventHandler { + private final CurrentParticipantManager currentParticipantManager; private static final String ROOM_PREFIX = "/topic/rooms/"; private static final String JOIN_SUFFIX = "/join"; @@ -47,7 +47,7 @@ public void handleSubscribe(SessionSubscribeEvent event) { @EventListener public void handleUnsubscribe(RoomExitEvent event) { - participantManager.removeParticipant(event.memberId(), event.roomId()); + currentParticipantManager.removeParticipant(event.memberId(), event.roomId()); } private boolean isRoomJoinPath(String destination) { @@ -55,7 +55,7 @@ private boolean isRoomJoinPath(String destination) { } private void validateParticipantRoomJoin(Long roomId, Long memberId) { - Set participants = participantManager.getRoomParticipants(roomId); + Set participants = currentParticipantManager.getRoomParticipants(roomId); if (participants == null || !participants.contains(memberId)) { throw new ErrorException(UNAUTHORIZED_NOT_JOIN_ROOM); } diff --git a/src/main/java/com/oronaminc/join/websocket/config/ParticipantManager.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java similarity index 93% rename from src/main/java/com/oronaminc/join/websocket/config/ParticipantManager.java rename to src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java index 6e999ba..3716745 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/ParticipantManager.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java @@ -1,4 +1,4 @@ -package com.oronaminc.join.websocket.config; +package com.oronaminc.join.websocket.session; import static com.oronaminc.join.global.exception.ErrorCode.*; @@ -11,7 +11,7 @@ import com.oronaminc.join.global.exception.ErrorException; @Component -public class ParticipantManager { +public class CurrentParticipantManager { private final Map> roomParticipants = new ConcurrentHashMap<>(); public Set getRoomParticipants(Long roomId) { diff --git a/src/main/java/com/oronaminc/join/websocket/config/CustomWebSocketHandlerDecorator.java b/src/main/java/com/oronaminc/join/websocket/session/CustomWebSocketHandlerDecorator.java similarity index 95% rename from src/main/java/com/oronaminc/join/websocket/config/CustomWebSocketHandlerDecorator.java rename to src/main/java/com/oronaminc/join/websocket/session/CustomWebSocketHandlerDecorator.java index 9f4ccc4..6d67c1f 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/CustomWebSocketHandlerDecorator.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CustomWebSocketHandlerDecorator.java @@ -1,4 +1,4 @@ -package com.oronaminc.join.websocket.config; +package com.oronaminc.join.websocket.session; import java.security.Principal; import java.util.Objects; @@ -9,7 +9,7 @@ import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.WebSocketHandlerDecorator; -import com.oronaminc.join.room.dto.RoomExitEvent; +import com.oronaminc.join.room.event.RoomExitEvent; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebsocketSessionManager.java b/src/main/java/com/oronaminc/join/websocket/session/WebsocketSessionManager.java similarity index 95% rename from src/main/java/com/oronaminc/join/websocket/config/WebsocketSessionManager.java rename to src/main/java/com/oronaminc/join/websocket/session/WebsocketSessionManager.java index 79ea36c..48c740c 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebsocketSessionManager.java +++ b/src/main/java/com/oronaminc/join/websocket/session/WebsocketSessionManager.java @@ -1,4 +1,4 @@ -package com.oronaminc.join.websocket.config; +package com.oronaminc.join.websocket.session; import java.io.IOException; import java.util.Map; diff --git a/src/main/java/com/oronaminc/join/websocket/config/StompErrorHandler.java b/src/main/java/com/oronaminc/join/websocket/stomp/StompErrorHandler.java similarity index 98% rename from src/main/java/com/oronaminc/join/websocket/config/StompErrorHandler.java rename to src/main/java/com/oronaminc/join/websocket/stomp/StompErrorHandler.java index 8b93cc9..80668ab 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/StompErrorHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/stomp/StompErrorHandler.java @@ -1,13 +1,7 @@ -package com.oronaminc.join.websocket.config; +package com.oronaminc.join.websocket.stomp; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.global.exception.ErrorResponse; import java.nio.charset.StandardCharsets; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; import org.springframework.messaging.simp.stomp.StompCommand; @@ -16,6 +10,15 @@ import org.springframework.util.MimeTypeUtils; import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.global.exception.ErrorResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j @Configuration @RequiredArgsConstructor diff --git a/src/main/java/com/oronaminc/join/websocket/config/StompPrincipal.java b/src/main/java/com/oronaminc/join/websocket/stomp/StompPrincipal.java similarity index 84% rename from src/main/java/com/oronaminc/join/websocket/config/StompPrincipal.java rename to src/main/java/com/oronaminc/join/websocket/stomp/StompPrincipal.java index b9c3db5..236bfa0 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/StompPrincipal.java +++ b/src/main/java/com/oronaminc/join/websocket/stomp/StompPrincipal.java @@ -1,4 +1,4 @@ -package com.oronaminc.join.websocket.config; +package com.oronaminc.join.websocket.stomp; import java.security.Principal; import lombok.AllArgsConstructor; From 977953797d3a448497d4415d661727db4b6dcc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=EA=B2=BD=EC=A4=80?= Date: Thu, 17 Jul 2025 15:07:57 +0900 Subject: [PATCH 10/74] =?UTF-8?q?feat:=20CD=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=EC=B6=95=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: .gitignore 추가 * feat: release-workflow, Dockerfile 추가 * fix: main에서 release로 변경 --- .github/workflows/release-workflow.yml | 77 ++++++++++++++++++++++++++ Dockerfile | 24 ++++++++ build.gradle | 2 +- 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-workflow.yml create mode 100644 Dockerfile diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml new file mode 100644 index 0000000..c1acc51 --- /dev/null +++ b/.github/workflows/release-workflow.yml @@ -0,0 +1,77 @@ +name: OromaminC Backend Service Release + +on: + push: + branches: + - release + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + + tagging: + name: 태깅 및 릴리즈 + runs-on: ubuntu-latest + outputs: + tag_name: ${{ steps.tag_version.outputs.new_tag }} + + steps: + - uses: actions/checkout@v4 + + - name: versioning and tagging + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: releasing + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} + + build-image: + name: 도커 이미지 빌드 + runs-on: ubuntu-latest + needs: tagging + + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Check out Repository + uses: actions/checkout@v4 + + - name: Setting for Developent + run: echo "${{ secrets.APPLICATION_DEV_YML }}" > src/main/resources/application-dev.yml + + - name: Sign in github container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha + type=raw,value=${{ needs.tagging.outputs.tag_name }} + type=raw,value=latest + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94b6ec4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM gradle:jdk21 as builder + +WORKDIR /libs + +COPY gradlew . +COPY gradle gradle +COPY build.gradle . +COPY settings.gradle . + +RUN ./gradlew dependencies --no-daemon || true + +COPY src src + +RUN ./gradlew build --no-daemon -x test + + +FROM openjdk:21-slim + +WORKDIR /app + +COPY --from=builder /libs/build/libs/*.jar app.jar + +ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar", "app.jar"] + diff --git a/build.gradle b/build.gradle index 5938904..83ce1a5 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'com.oronaminc' -version = '0.0.1-SNAPSHOT' +version = '0.1' java { toolchain { From 7e0a491d2134bea2d23de8eef978646e9e9bab7b Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:04:35 +0900 Subject: [PATCH 11/74] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 파일 업로드 로직 수정 * chore: log debug 변경 * chore: 발표자료 수정 로직 변경 * chore: S3 이동 실패시 예외처리 추가 --- .../document/event/DocumentCreateEvent.java | 3 ++ .../document/event/DocumentEventHandler.java | 34 +++++++++++++++++ .../document/service/DocumentService.java | 37 ++++++++++++++++++- .../join/global/exception/ErrorCode.java | 3 ++ .../join/infra/service/S3Service.java | 24 ++++++++++-- .../join/room/service/RoomService.java | 5 +-- 6 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/document/event/DocumentCreateEvent.java create mode 100644 src/main/java/com/oronaminc/join/document/event/DocumentEventHandler.java diff --git a/src/main/java/com/oronaminc/join/document/event/DocumentCreateEvent.java b/src/main/java/com/oronaminc/join/document/event/DocumentCreateEvent.java new file mode 100644 index 0000000..d32cb8a --- /dev/null +++ b/src/main/java/com/oronaminc/join/document/event/DocumentCreateEvent.java @@ -0,0 +1,3 @@ +package com.oronaminc.join.document.event; + +public record DocumentCreateEvent(String objectKey, String fileName) { } diff --git a/src/main/java/com/oronaminc/join/document/event/DocumentEventHandler.java b/src/main/java/com/oronaminc/join/document/event/DocumentEventHandler.java new file mode 100644 index 0000000..8ec126b --- /dev/null +++ b/src/main/java/com/oronaminc/join/document/event/DocumentEventHandler.java @@ -0,0 +1,34 @@ +package com.oronaminc.join.document.event; + + +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.infra.service.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class DocumentEventHandler { + + private final S3Service s3Service; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleDocumentEvent(DocumentCreateEvent event) { + String newKey = "documents/" + event.fileName(); + + try { + s3Service.moveObject(event.objectKey(), newKey); + + log.debug("✅ S3 파일 이동 성공: {} → {}", event.objectKey(), newKey); + } catch (Exception e) { + log.error("❌ S3 파일 이동 실패: {} → {}, 이유: {}", event.objectKey(), newKey, e.getMessage(), e); + throw new ErrorException(ErrorCode.MOVEMENT_FILE_FAILED); + } + } +} diff --git a/src/main/java/com/oronaminc/join/document/service/DocumentService.java b/src/main/java/com/oronaminc/join/document/service/DocumentService.java index e50e472..6ada500 100644 --- a/src/main/java/com/oronaminc/join/document/service/DocumentService.java +++ b/src/main/java/com/oronaminc/join/document/service/DocumentService.java @@ -1,5 +1,10 @@ package com.oronaminc.join.document.service; + +import com.oronaminc.join.document.domain.Document; +import com.oronaminc.join.document.event.DocumentCreateEvent; +import org.springframework.context.ApplicationEventPublisher; + import java.util.UUID; import org.springframework.stereotype.Service; @@ -17,12 +22,15 @@ import lombok.RequiredArgsConstructor; + @Service @RequiredArgsConstructor public class DocumentService { private final DocumentRepository documentRepository; private final S3Service s3Service; + private final ApplicationEventPublisher publisher; + private final DocumentReader documentReader; @Transactional public void deleteByRoomId(Long roomId) { @@ -34,8 +42,16 @@ public DocumentResponse generatePresignedUrl(DocumentRequest request, String mem throw new ErrorException(ErrorCode.UNAUTHORIZED_MEMBER); } + String OriginalFileName = request.fileName(); + String extension = ""; + + int dotIndex = OriginalFileName.lastIndexOf('.'); + if (dotIndex != -1) { + extension = OriginalFileName.substring(dotIndex); + } + String uuid = UUID.randomUUID().toString(); - String objectKey = "documents/" + uuid + "_" + request.fileName(); + String objectKey = "temp/" + uuid + extension; String presignedUrl = s3Service.generatePresignedUrl(objectKey); return new DocumentResponse(presignedUrl, objectKey); @@ -45,7 +61,24 @@ public DocumentResponse generatePresignedUrl(DocumentRequest request, String mem public void saveDocument(String objectKey, Room room) { String fileName = objectKey.replaceAll("^.*/",""); - documentRepository.save(DocumentMapper.toDocument(objectKey, fileName, room)); + String newKey = "documents/" + fileName; + + documentRepository.save(DocumentMapper.toDocument(newKey, fileName, room)); + publisher.publishEvent(new DocumentCreateEvent(objectKey, fileName)); + } + + @Transactional + public void updateDocument(String objectKey, Long roomId) { + Document document = documentReader.getByRoomId(roomId); + String fileName = objectKey.replaceAll("^.*/",""); + + String oldKey = document.getFileUrl(); + String newKey = "documents/" + fileName; + + document.update(newKey); + + s3Service.deleteFile(oldKey); + publisher.publishEvent(new DocumentCreateEvent(objectKey, fileName)); } } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index bd4260b..860ac1e 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -34,6 +34,9 @@ public enum ErrorCode { FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), + MOVEMENT_FILE_FAILED("FILE-003", "파일 이동이 실패하였습니다.", INTERNAL_SERVER_ERROR), + DELETE_FILE_FAILED("FILE-004", "파일 삭제에 실패하였습니다.", INTERNAL_SERVER_ERROR), + NOT_FOUND_ROOM_QUESTION("QUESTION-001", "질문을 해당 방에서 찾을 수 없습니다.", NOT_FOUND), NOT_FOUND_QUESTION("QUESTION-002", "질문을 찾을 수 없습니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/infra/service/S3Service.java b/src/main/java/com/oronaminc/join/infra/service/S3Service.java index 2f88641..3bc2261 100644 --- a/src/main/java/com/oronaminc/join/infra/service/S3Service.java +++ b/src/main/java/com/oronaminc/join/infra/service/S3Service.java @@ -1,15 +1,13 @@ package com.oronaminc.join.infra.service; +import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.HeadObjectRequest; -import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.*; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; @@ -53,6 +51,7 @@ public void deleteFile(String key) { } } catch (S3Exception e) { log.error("S3Exception: {}", e.getMessage(), e); + throw new ErrorException(ErrorCode.DELETE_FILE_FAILED); } } @@ -68,4 +67,21 @@ public Boolean isFileExist(String key) { return false; } } + + public void moveObject(String objectKey, String destinationKey) { + CopyObjectRequest copyRequest = CopyObjectRequest.builder() + .sourceBucket(bucket) + .sourceKey(objectKey) + .destinationBucket(bucket) + .destinationKey(destinationKey) + .build(); + s3Client.copyObject(copyRequest); + + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build(); + + s3Client.deleteObject(deleteRequest); + } } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 9502d8f..6fb42c5 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -70,8 +70,8 @@ public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, Room room = RoomMapper.toRoom(createRoomRequest, code); roomRepository.save(room); - documentService.saveDocument(createRoomRequest.documentUrl(), room); participantService.savePresenterAndTeam(presenterEmail, createRoomRequest.teamEmail(), room); + documentService.saveDocument(createRoomRequest.documentUrl(), room); return RoomMapper.toCreateRoomResponse(room); } @@ -105,15 +105,14 @@ public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomR participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); - Document document = documentReader.getByRoomId(roomId); if (room.getRoomStatus().equals(RoomStatus.STARTED)) { throw new ErrorException(BAD_REQUEST_ROOM_STARTED); } - document.update(updateRoomRequest.documentUrl()); room.update(updateRoomRequest); participantService.updateTeam(room, updateRoomRequest.teamEmail()); + documentService.updateDocument(updateRoomRequest.documentUrl(), roomId); } @Transactional From 6bf41e98e4645800b7607b33c82f035c7f136518 Mon Sep 17 00:00:00 2001 From: SeungTae <122506273+gffd94@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:04:49 +0900 Subject: [PATCH 12/74] =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/answer/dto/AnswerCreateResponse.java | 3 - .../join/answer/dto/AnswerRequest.java | 1 - .../join/answer/service/AnswerService.java | 28 +++------ .../join/answer/util/PermissionType.java | 18 ++++++ .../join/answer/util/PermissionValidator.java | 32 +++++----- .../join/global/exception/ErrorCode.java | 8 +-- .../join/global/ratelimit/RateLimitType.java | 8 +++ .../api/AnswerWebsocketController.java | 25 +++++--- .../join/answer/api/PermissionValidTests.java | 8 +-- .../answer/service/AnswerServiceTests.java | 63 ++++++------------- 10 files changed, 92 insertions(+), 102 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/answer/util/PermissionType.java diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java index 391426d..191ab18 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java @@ -10,7 +10,6 @@ @Builder @Schema(description = "WebSocket STOMP 통신 답변 응답 DTO") public record AnswerCreateResponse( - //TODO: QuestionCreateResponse와 유사-> 둘중 하나만? @Schema(description = "답변이 생성될 질문 ID") Long questionId, @Schema(description = "답변 생성/삭제/수정 상태", example = "CREATE") @@ -18,8 +17,6 @@ public record AnswerCreateResponse( @Schema(description = "답변 ID", example = "11") Long answerId, @Schema(description = "답변 내용", example = "답변입니다.") - @NotBlank(message = "답변 내용을 입력해주시기 바랍니다.") - @Size(max = 300, message = "답변 내용은 최대 300자까지 입력할 수 있습니다.") String content, @Schema(description = "답변 내용에 대한 공감 수", example = "23") int emojiCount, diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java index f9c3ead..9d7366d 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java @@ -6,7 +6,6 @@ @Schema(description = "답변 생성/수정 요청 DTO") public record AnswerRequest( - //TODO: 빈값 or " " (space) 처리 @NotBlank(message = "답변 내용을 입력해주시기 바랍니다.") @Size(max = 300, message = "답변 내용은 최대 300자까지 입력할 수 있습니다.") @Schema(description = "답변 내용", example = "답변입니다.") diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index 5895ec6..6436723 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -1,15 +1,14 @@ package com.oronaminc.join.answer.service; -import static com.oronaminc.join.global.exception.ErrorCode.BADREQUEST_DUPLICATION_ANSWER; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.mapper.AnswerMapper; +import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.service.EmojiReader; -import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.service.MemberReader; import com.oronaminc.join.participant.service.ParticipantService; @@ -28,12 +27,12 @@ public class AnswerService { private final AnswerRepository answerRepository; - private final ParticipantService participantService; private final QuestionReader questionReader; private final MemberReader memberReader; private final AnswerReader answerReader; private final RoomReader roomReader; private final EmojiReader emojiReader; + private final PermissionValidator permissionValidator; @Transactional public Answer create(Long roomId, Long memberId, Long questionId, @@ -41,19 +40,12 @@ public Answer create(Long roomId, Long memberId, Long questionId, Member member = memberReader.getById(memberId); Room room = roomReader.getById(roomId); - Question question = questionReader.getByIdAndRoomId(questionId, roomId); - - participantService.validateParticipant(member.getId(), room.getId()); - - if (answerReader.existsByQuestionIdAndMemberId(question.getId(), member.getId())) { - throw new ErrorException(BADREQUEST_DUPLICATION_ANSWER); - } - + Question question = questionReader.getByIdAndRoomId(questionId, room.getId()); + permissionValidator.validateAnswerCreatePermission(room.getId(), member.getId(), question); Answer answer = AnswerMapper.toEntity(question, member, request); - answerRepository.save(answer); + return answerRepository.save(answer); - return answer; } @Transactional @@ -71,8 +63,8 @@ public AnswerGetResponse getAnswer(Long roomId, Long questionId, Long memberId) } @Transactional - public Answer update(Long answerId, AnswerRequest request) { - Answer answer = answerReader.getById(answerId); + public Answer update(Long answerId, Long memberId, AnswerRequest request) { + Answer answer = permissionValidator.validateAnswerUpdatePermission(answerId, memberId); answer.updataContent(request.content()); @@ -80,8 +72,8 @@ public Answer update(Long answerId, AnswerRequest request) { } @Transactional - public void delete(Long answerId) { - Answer answer = answerReader.getById(answerId); + public void delete(Long answerId, Long memberId) { + Answer answer = permissionValidator.validateAnswerDeletePermission(answerId, memberId); answerRepository.delete(answer); } diff --git a/src/main/java/com/oronaminc/join/answer/util/PermissionType.java b/src/main/java/com/oronaminc/join/answer/util/PermissionType.java new file mode 100644 index 0000000..0f3744a --- /dev/null +++ b/src/main/java/com/oronaminc/join/answer/util/PermissionType.java @@ -0,0 +1,18 @@ +package com.oronaminc.join.answer.util; + +import com.oronaminc.join.global.exception.ErrorCode; +import lombok.Getter; + + +@Getter +public enum PermissionType { + CREATE, DELETE; + + public ErrorCode toErrorCode() { + return switch (this) { + case CREATE -> ErrorCode.UNAUTHORIZED_ROLE_ANSWER; + case DELETE -> ErrorCode.UNAUTHORIZED_DELETE_ANSWER; + }; + } + +} diff --git a/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java b/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java index 294fe37..072c55f 100644 --- a/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java +++ b/src/main/java/com/oronaminc/join/answer/util/PermissionValidator.java @@ -1,8 +1,6 @@ package com.oronaminc.join.answer.util; -import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_DELETE_ANSWER; import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_EDIT_ANSWER; -import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_ROLE_ANSWER; import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; @@ -22,40 +20,40 @@ public class PermissionValidator { private final ParticipantReader participantReader; private final AnswerReader answerReader; - public void validateAnswerPermission(Long roomId, Long memberId) { - // TODO: 생성시 조건 ( null 이면 안됨, " " 안됨, 팀원이나 발표자, 질문 작성자 본인만 생성가능 ) - Participant participant = participantReader.getByRoomIdAndMemberId(roomId, memberId); - ParticipantType type = participant.getParticipantType(); - - if (type == ParticipantType.GUEST) { - throw new ErrorException(UNAUTHORIZED_ROLE_ANSWER); - } + public void validateAnswerCreatePermission(Long roomId, Long memberId, Question question) { + validatePermission(roomId, memberId, question, PermissionType.CREATE); } - public void validateAnswerUpdatePermission(Long answerId, Long memberId) { + public Answer validateAnswerUpdatePermission(Long answerId, Long memberId) { Answer answer = answerReader.getById(answerId); if (!answer.getMember().getId().equals(memberId)) { throw new ErrorException(UNAUTHORIZED_EDIT_ANSWER); } + + return answer; } - public void validateAnswerDeletePermission(Long answerId, Long memberId) { + public Answer validateAnswerDeletePermission(Long answerId, Long memberId) { Answer answer = answerReader.getById(answerId); - Room room = answer.getQuestion().getRoom(); Question question = answer.getQuestion(); - Participant participant = participantReader.getByRoomIdAndMemberId(room.getId(), memberId); - ParticipantType type = participant.getParticipantType(); + validatePermission(room.getId(), memberId, question, PermissionType.DELETE); + + return answer; + } + private void validatePermission(Long roomId, Long memberId, Question question, + PermissionType permissionType) { + Participant participant = participantReader.getByRoomIdAndMemberId(roomId, memberId); + ParticipantType type = participant.getParticipantType(); boolean isQuestionWriter = question.getMember().getId().equals(memberId); if (!(isQuestionWriter || type == ParticipantType.TEAM || type == ParticipantType.PRESENTER)) { - throw new ErrorException(UNAUTHORIZED_DELETE_ANSWER); + throw new ErrorException(permissionType.toErrorCode()); } - } } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 860ac1e..5fc9cd7 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -44,12 +44,12 @@ public enum ErrorCode { UNAUTHORIZED_DELETE_QUESTION("QUESTION-004", "작성자 및 관리자만 질문을 삭제할 수 있습니다.", UNAUTHORIZED), TOO_MANY_REQUESTS_QUESTION("QUESTION-005", "잠시 후 다시 시도해주세요.", TOO_MANY_REQUESTS), - UNAUTHORIZED_ROLE_ANSWER("ANSWER-001", "팀원 또는 발표자만 댓글을 작성할 수 있습니다.", UNAUTHORIZED), + UNAUTHORIZED_ROLE_ANSWER("ANSWER-001", "질문 작성자 또는 팀원과 발표자만 댓글을 작성할 수 있습니다.", UNAUTHORIZED), NOT_FOUND_EXIST_ANSWER("ANSWER-002", "해당 질문에 대한 답변이 존재하지 않습니다.", NOT_FOUND), NOT_FOUND_ANSWER("ANSWER-003", "답변이 존재하지 않습니다.", NOT_FOUND), - BADREQUEST_DUPLICATION_ANSWER("ANSWER-004", "이미 답변한 질문입니다.", BAD_REQUEST), - UNAUTHORIZED_EDIT_ANSWER("ANSWER-005", "작성자가 아니면 해당 댓글을 수정할 수 없습니다.", UNAUTHORIZED), - UNAUTHORIZED_DELETE_ANSWER("ANSWER-006", "작성자 혹은 팀원, 발표자가 아니면 해당 댓글을 삭제할 수 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_EDIT_ANSWER("ANSWER-004", "작성자가 아니면 해당 댓글을 수정할 수 없습니다.", UNAUTHORIZED), + UNAUTHORIZED_DELETE_ANSWER("ANSWER-005", "작성자 혹은 팀원, 발표자가 아니면 해당 댓글을 삭제할 수 없습니다.", UNAUTHORIZED), + TOO_MANY_REQUESTS_ANSWER("ANSWER-006", "잠시 후 다시 시도해주세요.", UNAUTHORIZED), ACCESS_DENIED_SESSION("SESSION-1201", "접근 권한이 없습니다.", FORBIDDEN), diff --git a/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java index 2e23dec..59a7ff4 100644 --- a/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java +++ b/src/main/java/com/oronaminc/join/global/ratelimit/RateLimitType.java @@ -19,6 +19,14 @@ public enum RateLimitType { .capacity(3) .refillIntervally(3, Duration.ofSeconds(1)) .build() + ), + + CREATE_ANSWER( + "CREATE_ANSWER:{}:{}:{}", + Bandwidth.builder() + .capacity(5) + .refillIntervally(5, Duration.ofSeconds(10)) + .build() ); private final String format; diff --git a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java index d87702b..313e60f 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java @@ -1,5 +1,6 @@ package com.oronaminc.join.websocket.api; +import static com.oronaminc.join.global.exception.ErrorCode.TOO_MANY_REQUESTS_ANSWER; import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_MEMBER; import com.oronaminc.join.answer.domain.Answer; @@ -9,8 +10,10 @@ import com.oronaminc.join.answer.dto.AnswerUpdateResponse; import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; -import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.global.ratelimit.RateLimitService; +import com.oronaminc.join.global.ratelimit.RateLimitType; +import io.github.bucket4j.Bucket; import jakarta.validation.Valid; import java.security.Principal; import lombok.RequiredArgsConstructor; @@ -27,7 +30,7 @@ public class AnswerWebsocketController { private final AnswerService answerService; - private final PermissionValidator permissionValidator; + private final RateLimitService rateLimitService; @MessageMapping("/rooms/{roomId}/question/{questionId}/answers/create") @SendTo("/topic/rooms/{roomId}/answers") @@ -39,11 +42,15 @@ public AnswerCreateResponse create( ) { Long memberId = getMemberId(principal); - permissionValidator.validateAnswerPermission(roomId, memberId); + Bucket bucket = rateLimitService.getBucket(RateLimitType.CREATE_ANSWER, roomId, memberId, questionId); + + if (!bucket.tryConsume(1)) { + throw new ErrorException(TOO_MANY_REQUESTS_ANSWER); + } Answer answer = answerService.create(roomId, memberId, questionId, request); - log.info("답변 메세지 = {}", request.content()); + log.info("답변 메세지 = {}", answer.getContent()); return AnswerMapper.toAnswerCreateResponse(answer); } @@ -58,9 +65,9 @@ public AnswerUpdateResponse update( Long memberId = getMemberId(principal); - permissionValidator.validateAnswerUpdatePermission(answerId, memberId); + Answer answer = answerService.update(answerId, memberId, request); - Answer answer = answerService.update(answerId, request); + log.info("수정 메세지 = {}", answer.getContent()); return AnswerMapper.toAnswerUpdateResponse(answer); } @@ -68,16 +75,14 @@ public AnswerUpdateResponse update( @MessageMapping("/answers/{answerId}/delete") @SendTo("/topic/rooms/{roomId}/answers") public AnswerDeleteResponse delete( - @DestinationVariable Long roomId, - @DestinationVariable Long questionId, @DestinationVariable Long answerId, Principal principal ) { Long memberId = getMemberId(principal); - permissionValidator.validateAnswerDeletePermission(answerId, memberId); + answerService.delete(answerId, memberId); - answerService.delete(answerId); + log.info("삭제되었습니다."); return new AnswerDeleteResponse(answerId, "DELETE"); } diff --git a/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java b/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java index a7d3146..326fe16 100644 --- a/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java +++ b/src/test/java/com/oronaminc/join/answer/api/PermissionValidTests.java @@ -65,13 +65,13 @@ void setUp() { } @Test - @DisplayName("TEAM or PRESENTER가 아닌 GUEST가 답변시 예외 발생") - void validateAnswerPermission_fail_not_team_or_presenter() { + @DisplayName("TEAM or PRESENTER or 작성자가 아닌 참여자가 답변시 예외 발생") + void validateAnswerPermission_fail_not_team_or_presenter_orWriter() { // given given(participantReader.getByRoomIdAndMemberId(1L, 1L)).willReturn(participant); // when & then - assertThatThrownBy(() -> permissionValidator.validateAnswerPermission(1L, 1L)) + assertThatThrownBy(() -> permissionValidator.validateAnswerCreatePermission(1L, 1L, question)) .isInstanceOf(ErrorException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.UNAUTHORIZED_ROLE_ANSWER); } @@ -84,7 +84,7 @@ void validateAnswerPermission_fail_not_found_participant() { .willThrow(new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT)); // when & then - assertThatThrownBy(() -> permissionValidator.validateAnswerPermission(1L, 1L)) + assertThatThrownBy(() -> permissionValidator.validateAnswerCreatePermission(1L, 1L, question)) .isInstanceOf(ErrorException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND_PARTICIPANT); } diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index 8256e18..b5f14af 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -1,7 +1,6 @@ package com.oronaminc.join.answer.service; import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_ROOM; -import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -11,8 +10,8 @@ import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; @@ -28,7 +27,6 @@ import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.question.service.QuestionService; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; @@ -62,6 +60,11 @@ public class AnswerServiceTests { private AnswerReader answerReader; @Mock private EmojiReader emojiReader; + @Mock + private PermissionValidator permissionValidator; + @Mock + private ParticipantReader participantReader; + private Member mockMember; private Room mockRoom; @@ -121,13 +124,12 @@ void createAnswer_success() { .question(mockQuestion) .emojiCount(0L) .version(1) - .content("답변입니다") + .content("답변입니다.") .build(); given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.existsByQuestionIdAndMemberId(1L, 1L)).willReturn(false); given(answerRepository.save(any(Answer.class))).willReturn(answer); // when @@ -194,11 +196,13 @@ void updateAnswer_success() { .content("기존 내용") .build(); - given(answerReader.getById(1L)).willReturn(answer); + given(permissionValidator.validateAnswerUpdatePermission(1L, 1L)) + .willReturn(answer); + AnswerRequest request = new AnswerRequest("수정된 내용"); // when - Answer result = answerService.update(answer.getId(), request); + Answer result = answerService.update(answer.getId(), answer.getMember().getId(), request); // then assertThat(result.getContent()).isEqualTo("수정된 내용"); @@ -219,43 +223,27 @@ void createAnswer_member_fail() { } - @Test - @DisplayName("존재하지 않는 room이 들어오면 예외 발생") - void createAnswer_room_fail() { - // given - given(memberReader.getById(1L)).willReturn(mockMember); - given(roomReader.getById(anyLong())).willThrow( - new ErrorException(NOT_FOUND_ROOM)); - // when & then - assertThatThrownBy(() -> answerService.create(1L, 1L, 1L, request)) - .isInstanceOf(ErrorException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND_ROOM); - - } - @Test - @DisplayName("존재하지 않는 participant가 들어오면 예외 발생") - void createAnswer_participant_fail() { + @DisplayName("존재하지 않는 room이 들어오면 예외 발생") + void createAnswer_room_fail() { // given given(memberReader.getById(1L)).willReturn(mockMember); - given(roomReader.getById(1L)).willReturn(mockRoom); - given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - willThrow(new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT)) - .given(participantService) - .validateParticipant(1L, 1L); - + given(roomReader.getById(anyLong())).willThrow( + new ErrorException(NOT_FOUND_ROOM)); // when & then assertThatThrownBy(() -> answerService.create(1L, 1L, 1L, request)) .isInstanceOf(ErrorException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND_PARTICIPANT); + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND_ROOM); } + @Test @DisplayName("존재하지_않는_질문이_들어오면_예외_발생") void createAnswer_question_fail() { // given given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willThrow( new ErrorException(ErrorCode.NOT_FOUND_QUESTION)); @@ -266,21 +254,6 @@ void createAnswer_question_fail() { } - @Test - @DisplayName("중복_답변_남길시_예외_발생") - void createAnswer_duplicate_fail() { - // given - given(memberReader.getById(1L)).willReturn(mockMember); - given(roomReader.getById(1L)).willReturn(mockRoom); - given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.existsByQuestionIdAndMemberId(1L, 1L)).willReturn(true); - - // when & then - assertThatThrownBy(() -> answerService.create(1L, 1L, 1L, request)) - .isInstanceOf(ErrorException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.BADREQUEST_DUPLICATION_ANSWER); - - } } From bb32066eda5c7e7ed7bd69a67f7e9671d20d65d3 Mon Sep 17 00:00:00 2001 From: chw0912 Date: Fri, 18 Jul 2025 10:28:17 +0900 Subject: [PATCH 13/74] =?UTF-8?q?chore:=20resources=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-workflow.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index c1acc51..8e073b0 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -49,7 +49,9 @@ jobs: uses: actions/checkout@v4 - name: Setting for Developent - run: echo "${{ secrets.APPLICATION_DEV_YML }}" > src/main/resources/application-dev.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_DEV_YML }}" > src/main/resources/application-dev.yml - name: Sign in github container registry uses: docker/login-action@v3 From aea5f027ca47dc8bc614fbd89f27afa6bd8ff35a Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:28:09 +0900 Subject: [PATCH 14/74] =?UTF-8?q?feat:=20workflow=EC=97=90=20EC2=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 --- .github/workflows/release-workflow.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index c1acc51..08f7cd1 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -49,7 +49,9 @@ jobs: uses: actions/checkout@v4 - name: Setting for Developent - run: echo "${{ secrets.APPLICATION_DEV_YML }}" > src/main/resources/application-dev.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_DEV_YML }}" > src/main/resources/application-dev.yml - name: Sign in github container registry uses: docker/login-action@v3 @@ -75,3 +77,22 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + deploy: + name: EC2 자동 배포 + runs-on: ubuntu-latest + needs: build-image + + steps: + - name: EC2에 SSH로 접속 후 배포 + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + script: | + cd ${{ secrets.EC2_DEPLOY_DIR }} + docker compose pull + docker compose down + docker compose up -d \ No newline at end of file From a7fea2731c28cf19955ff228da396b1cda3956ee Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:33:58 +0900 Subject: [PATCH 15/74] =?UTF-8?q?feat:=20workflow=EC=97=90=20EC2=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#78)=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 --- .github/workflows/release-workflow.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index 8e073b0..08f7cd1 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -77,3 +77,22 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + deploy: + name: EC2 자동 배포 + runs-on: ubuntu-latest + needs: build-image + + steps: + - name: EC2에 SSH로 접속 후 배포 + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + script: | + cd ${{ secrets.EC2_DEPLOY_DIR }} + docker compose pull + docker compose down + docker compose up -d \ No newline at end of file From 4d341f80a7de2b79b3bb8884612bd1a1fd0299ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:42:11 +0900 Subject: [PATCH 16/74] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=EB=B0=A9=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=B0=8F=20=EC=9D=B8=EB=8D=B1=EC=8B=B1=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20feat=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 --- build.gradle | 5 + .../join/emoji/service/EmojiFacade.java | 6 +- .../join/global/config/CacheConfig.java | 40 +++++++ .../join/global/config/CacheType.java | 14 +++ .../join/global/dev/DevController.java | 2 + .../join/global/dev/HealthController.java | 28 +++++ .../join/member/security/SecurityConfig.java | 3 +- .../com/oronaminc/join/room/domain/Room.java | 2 + .../join/room/dto/CreateRoomRequest.java | 4 +- .../join/room/service/RoomReader.java | 13 +++ .../join/room/service/RoomService.java | 20 ++-- src/main/resources/static/favicon.ico | 0 .../join/emoji/service/EmojiFacadeTests.java | 34 +++--- .../join/room/service/RoomCacheTests.java | 108 ++++++++++++++++++ src/test/resources/application.yml | 11 ++ 15 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/config/CacheConfig.java create mode 100644 src/main/java/com/oronaminc/join/global/config/CacheType.java create mode 100644 src/main/java/com/oronaminc/join/global/dev/HealthController.java create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java diff --git a/build.gradle b/build.gradle index 83ce1a5..7945a4a 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-cache' testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' @@ -58,6 +59,10 @@ dependencies { // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' + + // caffeine + implementation 'com.github.ben-manes.caffeine:caffeine' + } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java index bba1635..8bc099c 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java @@ -1,12 +1,14 @@ package com.oronaminc.join.emoji.service; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; + import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; + import lombok.RequiredArgsConstructor; -import org.springframework.orm.ObjectOptimisticLockingFailureException; -import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor diff --git a/src/main/java/com/oronaminc/join/global/config/CacheConfig.java b/src/main/java/com/oronaminc/join/global/config/CacheConfig.java new file mode 100644 index 0000000..590e322 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/CacheConfig.java @@ -0,0 +1,40 @@ +package com.oronaminc.join.global.config; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Scheduler; + +@EnableCaching +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + List caches = Arrays.stream(CacheType.values()) + .map(cache -> new CaffeineCache( + cache.cacheName, + Caffeine.newBuilder() + .expireAfterWrite(cache.expireAfterWrite, TimeUnit.SECONDS) + .maximumSize(cache.maximumSize) + .scheduler(Scheduler.systemScheduler()) + .build() + ) + ) + .toList(); + + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(caches); + + return cacheManager; + } +} diff --git a/src/main/java/com/oronaminc/join/global/config/CacheType.java b/src/main/java/com/oronaminc/join/global/config/CacheType.java new file mode 100644 index 0000000..71af6e1 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/CacheType.java @@ -0,0 +1,14 @@ +package com.oronaminc.join.global.config; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum CacheType { + ROOM_BY_ID("roomById", 300, 1000), + ROOM_BY_SECRET_CODE("roomBySecretCode", 300, 1000) + ; + + public final String cacheName; + public final long expireAfterWrite; + public final long maximumSize; +} diff --git a/src/main/java/com/oronaminc/join/global/dev/DevController.java b/src/main/java/com/oronaminc/join/global/dev/DevController.java index 722dccd..5ab3347 100644 --- a/src/main/java/com/oronaminc/join/global/dev/DevController.java +++ b/src/main/java/com/oronaminc/join/global/dev/DevController.java @@ -18,11 +18,13 @@ import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.security.MemberDetails; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @RestController +@Tag(name = "개발용 API") @RequestMapping("/dev") @RequiredArgsConstructor public class DevController { diff --git a/src/main/java/com/oronaminc/join/global/dev/HealthController.java b/src/main/java/com/oronaminc/join/global/dev/HealthController.java new file mode 100644 index 0000000..b4defaa --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/dev/HealthController.java @@ -0,0 +1,28 @@ +package com.oronaminc.join.global.dev; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@Tag(name = "개발용 API") +public class HealthController { + + @Operation(summary = "애플리케이션 헬스체크") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/health") + public String health() { + return "Server is Healthy!"; + } + + @Operation(summary = "홈 헬스체크") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/") + public String home() { + return "It's Home!"; + } +} diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 89bdeb9..fd6861b 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -12,6 +13,7 @@ import lombok.RequiredArgsConstructor; @Configuration +@Profile("!test") @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -47,5 +49,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } - } diff --git a/src/main/java/com/oronaminc/join/room/domain/Room.java b/src/main/java/com/oronaminc/join/room/domain/Room.java index cd33c0a..2598be7 100644 --- a/src/main/java/com/oronaminc/join/room/domain/Room.java +++ b/src/main/java/com/oronaminc/join/room/domain/Room.java @@ -6,6 +6,7 @@ import com.oronaminc.join.global.entity.BaseEntity; import com.oronaminc.join.room.dto.RoomUpdateRequest; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -33,6 +34,7 @@ public class Room extends BaseEntity { private String title; private String description; + @Column(unique = true) private String secretCode; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java b/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java index 9e54e7b..c75396b 100644 --- a/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java +++ b/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java @@ -1,11 +1,11 @@ package com.oronaminc.join.room.dto; -import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.List; import org.hibernate.validator.constraints.Length; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -38,7 +38,7 @@ public record CreateRoomRequest( @NotNull @Size(max = 5) - @Schema(description = "발표방 추가한 팀원 목록", example = "{팀원1@example.com, 팀원2@example.com}") + @Schema(description = "발표방 추가한 팀원 목록") List teamEmail ) { } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomReader.java b/src/main/java/com/oronaminc/join/room/service/RoomReader.java index e0fef8f..d1e73a4 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomReader.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomReader.java @@ -4,6 +4,7 @@ import java.util.Optional; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; import com.oronaminc.join.global.exception.ErrorException; @@ -26,6 +27,12 @@ public Room getById(Long roomId) { .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); } + @Cacheable(cacheNames = "roomById") + public Room getCacheById(Long roomId) { + return findById(roomId) + .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); + } + public Optional findBySecretCode(String secretCode) { return roomRepository.findBySecretCode(secretCode); } @@ -35,6 +42,12 @@ public Room getBySecretCode(String secretCode) { .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); } + @Cacheable(cacheNames = "roomBySecretCode") + public Room getCacheBySecretCode(String secretCode) { + return this.findBySecretCode(secretCode) + .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); + } + public Boolean existsBySecretCode(String secretCode) { return roomRepository.existsBySecretCode(secretCode); } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 6fb42c5..e28e429 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.stream.Collectors; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,8 +65,10 @@ public class RoomService { private static final int CODE_LENGTH = 6; @Transactional - public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, - String presenterEmail) { + public CreateRoomResponse createRoom( + CreateRoomRequest createRoomRequest, + String presenterEmail + ) { String code = this.generateCode(); Room room = RoomMapper.toRoom(createRoomRequest, code); roomRepository.save(room); @@ -78,7 +81,7 @@ public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, @Transactional public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) { - Room room = roomReader.getBySecretCode(joinRoomRequest.secretCode()); + Room room = roomReader.getCacheBySecretCode(joinRoomRequest.secretCode()); if (room.getRoomStatus().equals(RoomStatus.BEFORE_START)) { throw new ErrorException(UNAUTHORIZED_JOIN_ROOM); } @@ -89,7 +92,7 @@ public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { participantService.validateParticipant(memberId, roomId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); Participant presenter = participantService.getPresenter(roomId); List team = participantService.getTeam(roomId); @@ -101,6 +104,7 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomRequest) { participantService.validatePresenter(roomId, memberId); @@ -116,6 +120,7 @@ public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomR } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void deleteRoom(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); @@ -132,6 +137,7 @@ public void deleteRoom(Long memberId, Long roomId) { } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoomStatus(Long memberId, Long roomId, RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); @@ -148,7 +154,7 @@ public void updateRoomStatus(Long memberId, Long roomId, @Transactional public RoomUpdateInfoResponse getRoomUpdateInfo(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); List team = participantService.getTeam(roomId); return RoomMapper.toRoomUpdateInfoResponse(room, team); } @@ -170,7 +176,7 @@ public ReportResponse getRoomReport(Long roomId, Long memberId) { throw new ErrorException(UNAUTHORIZED_REPORT_READ); } - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); Long totalView = participantReader.countTotalView(roomId); Long totalQuestions = questionReader.countByRoomId(roomId); Long totalAnswerByQuestion = answerReader.countAnsweredQuestionsByRoomId(roomId); @@ -217,7 +223,7 @@ private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuesti public RoomJoinResponse subscribeRoom(Long roomId, Long memberId) { participantService.validateParticipant(memberId, roomId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); if (!room.getRoomStatus().canSubscribeRoom) { throw new ErrorException(UNAUTHORIZED_SUBSCRIBE_ROOM); } diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java index aeb3d4c..b0c727e 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java @@ -1,27 +1,14 @@ package com.oronaminc.join.emoji.service; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; -import com.oronaminc.join.answer.service.AnswerReader; -import com.oronaminc.join.config.TestQueryDslConfig; -import com.oronaminc.join.emoji.dao.EmojiRepository; -import com.oronaminc.join.emoji.domain.Emoji; -import com.oronaminc.join.emoji.domain.TargetType; -import com.oronaminc.join.emoji.dto.EmojiRequest; -import com.oronaminc.join.member.dao.MemberRepository; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.room.dao.RoomRepository; -import com.oronaminc.join.room.domain.Room; -import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.service.RoomReader; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +18,21 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.oronaminc.join.answer.service.AnswerReader; +import com.oronaminc.join.config.TestQueryDslConfig; +import com.oronaminc.join.emoji.dao.EmojiRepository; +import com.oronaminc.join.emoji.domain.Emoji; +import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.emoji.dto.EmojiRequest; +import com.oronaminc.join.member.dao.MemberRepository; +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.question.service.QuestionReader; +import com.oronaminc.join.room.dao.RoomRepository; +import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.room.domain.RoomStatus; +import com.oronaminc.join.room.service.RoomReader; + @DataJpaTest @Import({EmojiFacade.class, EmojiService.class, MemberReader.class, EmojiReader.class, RoomReader.class, QuestionReader.class, AnswerReader.class, TestQueryDslConfig.class}) @@ -118,7 +120,7 @@ void deleteEmoji_success_test() throws InterruptedException { Room.builder() .title("제목") .description("내용") - .secretCode("123456") + .secretCode("654321") .emojiCount(emojiCount) .participantLimit(0) .endedAt(LocalDateTime.now()) diff --git a/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java new file mode 100644 index 0000000..9c92108 --- /dev/null +++ b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java @@ -0,0 +1,108 @@ +package com.oronaminc.join.room.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import com.oronaminc.join.member.dao.MemberRepository; +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.participant.dao.ParticipantRepository; +import com.oronaminc.join.participant.domain.Participant; +import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.room.dao.RoomRepository; +import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.room.domain.RoomStatus; +import com.oronaminc.join.room.domain.RoomType; +import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; + +@SpringBootTest +@ActiveProfiles("test") +@EnableCaching +class RoomCacheTests { + @Autowired + private RoomService roomService; + + @Autowired + private RoomReader roomReader; + + @MockitoSpyBean + private RoomRepository roomRepository; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @BeforeEach + void setUp() { + Room room = Room.builder() + .title("Test Room") + .description("Test Description") + .roomStatus(RoomStatus.BEFORE_START) + .roomType(RoomType.PUBLIC) + .build(); + + roomRepository.save(room); + + Member member = Member.builder() + .build(); + + memberRepository.save(member); + + Participant participant = Participant.builder() + .room(room) + .member(member) + .participantType(ParticipantType.PRESENTER) + .build(); + + participantRepository.save(participant); + } + + @Test + void 캐시가_적용되어_두번째_조회는_DB_접근이_없어야_한다() { + Long roomId = 1L; + + Room room = roomReader.getCacheById(roomId); + + int repeat = 10; + for (int count = 0; count < repeat; count++) { + Room cacheRoom = roomReader.getCacheById(roomId); + assertThat(room).isSameAs(cacheRoom); + } + + verify(roomRepository, times(1)).findById(roomId); + + Cache roomCache = cacheManager.getCache("roomById"); + Room cached = roomCache.get(roomId, Room.class); + assertThat(cached).isNotNull(); + } + + @Test + void updateRoomStatus_호출시_캐시가_삭제되어야_한다() { + Long roomId = 1L; + Long memberId = 1L; + + roomReader.getCacheById(roomId); + + Room cachedBefore = cacheManager.getCache("roomById").get(roomId, Room.class); + assertThat(cachedBefore).isNotNull(); + + roomService.updateRoomStatus(memberId, roomId, new RoomUpdateStatusRequest(RoomStatus.STARTED)); + + Room cachedAfter = cacheManager.getCache("roomById").get(roomId, Room.class); + assertThat(cachedAfter).isNull(); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 4ea2014..469322b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -10,3 +10,14 @@ spring: hibernate: ddl-auto: create +cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: test-bucket + stack: + auto: false + credentials: + access-key: dummy + secret-key: dummy \ No newline at end of file From ec731348cc371fac31897f125f8d9ae0d9a442e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:44:42 +0900 Subject: [PATCH 17/74] =?UTF-8?q?release:=20=EB=B3=91=ED=95=A9=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> --- build.gradle | 5 + .../join/emoji/service/EmojiFacade.java | 6 +- .../join/global/config/CacheConfig.java | 40 +++++++ .../join/global/config/CacheType.java | 14 +++ .../join/global/dev/DevController.java | 2 + .../join/global/dev/HealthController.java | 28 +++++ .../join/member/security/SecurityConfig.java | 3 +- .../com/oronaminc/join/room/domain/Room.java | 2 + .../join/room/dto/CreateRoomRequest.java | 4 +- .../join/room/service/RoomReader.java | 13 +++ .../join/room/service/RoomService.java | 20 ++-- src/main/resources/static/favicon.ico | 0 .../join/emoji/service/EmojiFacadeTests.java | 34 +++--- .../join/room/service/RoomCacheTests.java | 108 ++++++++++++++++++ src/test/resources/application.yml | 11 ++ 15 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/config/CacheConfig.java create mode 100644 src/main/java/com/oronaminc/join/global/config/CacheType.java create mode 100644 src/main/java/com/oronaminc/join/global/dev/HealthController.java create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java diff --git a/build.gradle b/build.gradle index 83ce1a5..7945a4a 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-cache' testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' @@ -58,6 +59,10 @@ dependencies { // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' + + // caffeine + implementation 'com.github.ben-manes.caffeine:caffeine' + } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java index bba1635..8bc099c 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java @@ -1,12 +1,14 @@ package com.oronaminc.join.emoji.service; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; + import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; + import lombok.RequiredArgsConstructor; -import org.springframework.orm.ObjectOptimisticLockingFailureException; -import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor diff --git a/src/main/java/com/oronaminc/join/global/config/CacheConfig.java b/src/main/java/com/oronaminc/join/global/config/CacheConfig.java new file mode 100644 index 0000000..590e322 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/CacheConfig.java @@ -0,0 +1,40 @@ +package com.oronaminc.join.global.config; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Scheduler; + +@EnableCaching +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + List caches = Arrays.stream(CacheType.values()) + .map(cache -> new CaffeineCache( + cache.cacheName, + Caffeine.newBuilder() + .expireAfterWrite(cache.expireAfterWrite, TimeUnit.SECONDS) + .maximumSize(cache.maximumSize) + .scheduler(Scheduler.systemScheduler()) + .build() + ) + ) + .toList(); + + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(caches); + + return cacheManager; + } +} diff --git a/src/main/java/com/oronaminc/join/global/config/CacheType.java b/src/main/java/com/oronaminc/join/global/config/CacheType.java new file mode 100644 index 0000000..71af6e1 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/CacheType.java @@ -0,0 +1,14 @@ +package com.oronaminc.join.global.config; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum CacheType { + ROOM_BY_ID("roomById", 300, 1000), + ROOM_BY_SECRET_CODE("roomBySecretCode", 300, 1000) + ; + + public final String cacheName; + public final long expireAfterWrite; + public final long maximumSize; +} diff --git a/src/main/java/com/oronaminc/join/global/dev/DevController.java b/src/main/java/com/oronaminc/join/global/dev/DevController.java index 722dccd..5ab3347 100644 --- a/src/main/java/com/oronaminc/join/global/dev/DevController.java +++ b/src/main/java/com/oronaminc/join/global/dev/DevController.java @@ -18,11 +18,13 @@ import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.security.MemberDetails; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @RestController +@Tag(name = "개발용 API") @RequestMapping("/dev") @RequiredArgsConstructor public class DevController { diff --git a/src/main/java/com/oronaminc/join/global/dev/HealthController.java b/src/main/java/com/oronaminc/join/global/dev/HealthController.java new file mode 100644 index 0000000..b4defaa --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/dev/HealthController.java @@ -0,0 +1,28 @@ +package com.oronaminc.join.global.dev; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@Tag(name = "개발용 API") +public class HealthController { + + @Operation(summary = "애플리케이션 헬스체크") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/health") + public String health() { + return "Server is Healthy!"; + } + + @Operation(summary = "홈 헬스체크") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/") + public String home() { + return "It's Home!"; + } +} diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 89bdeb9..fd6861b 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -12,6 +13,7 @@ import lombok.RequiredArgsConstructor; @Configuration +@Profile("!test") @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -47,5 +49,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } - } diff --git a/src/main/java/com/oronaminc/join/room/domain/Room.java b/src/main/java/com/oronaminc/join/room/domain/Room.java index cd33c0a..2598be7 100644 --- a/src/main/java/com/oronaminc/join/room/domain/Room.java +++ b/src/main/java/com/oronaminc/join/room/domain/Room.java @@ -6,6 +6,7 @@ import com.oronaminc.join.global.entity.BaseEntity; import com.oronaminc.join.room.dto.RoomUpdateRequest; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -33,6 +34,7 @@ public class Room extends BaseEntity { private String title; private String description; + @Column(unique = true) private String secretCode; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java b/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java index 9e54e7b..c75396b 100644 --- a/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java +++ b/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java @@ -1,11 +1,11 @@ package com.oronaminc.join.room.dto; -import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.List; import org.hibernate.validator.constraints.Length; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -38,7 +38,7 @@ public record CreateRoomRequest( @NotNull @Size(max = 5) - @Schema(description = "발표방 추가한 팀원 목록", example = "{팀원1@example.com, 팀원2@example.com}") + @Schema(description = "발표방 추가한 팀원 목록") List teamEmail ) { } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomReader.java b/src/main/java/com/oronaminc/join/room/service/RoomReader.java index e0fef8f..d1e73a4 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomReader.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomReader.java @@ -4,6 +4,7 @@ import java.util.Optional; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; import com.oronaminc.join.global.exception.ErrorException; @@ -26,6 +27,12 @@ public Room getById(Long roomId) { .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); } + @Cacheable(cacheNames = "roomById") + public Room getCacheById(Long roomId) { + return findById(roomId) + .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); + } + public Optional findBySecretCode(String secretCode) { return roomRepository.findBySecretCode(secretCode); } @@ -35,6 +42,12 @@ public Room getBySecretCode(String secretCode) { .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); } + @Cacheable(cacheNames = "roomBySecretCode") + public Room getCacheBySecretCode(String secretCode) { + return this.findBySecretCode(secretCode) + .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); + } + public Boolean existsBySecretCode(String secretCode) { return roomRepository.existsBySecretCode(secretCode); } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 6fb42c5..e28e429 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.stream.Collectors; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,8 +65,10 @@ public class RoomService { private static final int CODE_LENGTH = 6; @Transactional - public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, - String presenterEmail) { + public CreateRoomResponse createRoom( + CreateRoomRequest createRoomRequest, + String presenterEmail + ) { String code = this.generateCode(); Room room = RoomMapper.toRoom(createRoomRequest, code); roomRepository.save(room); @@ -78,7 +81,7 @@ public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, @Transactional public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) { - Room room = roomReader.getBySecretCode(joinRoomRequest.secretCode()); + Room room = roomReader.getCacheBySecretCode(joinRoomRequest.secretCode()); if (room.getRoomStatus().equals(RoomStatus.BEFORE_START)) { throw new ErrorException(UNAUTHORIZED_JOIN_ROOM); } @@ -89,7 +92,7 @@ public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { participantService.validateParticipant(memberId, roomId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); Participant presenter = participantService.getPresenter(roomId); List team = participantService.getTeam(roomId); @@ -101,6 +104,7 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomRequest) { participantService.validatePresenter(roomId, memberId); @@ -116,6 +120,7 @@ public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomR } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void deleteRoom(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); @@ -132,6 +137,7 @@ public void deleteRoom(Long memberId, Long roomId) { } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoomStatus(Long memberId, Long roomId, RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); @@ -148,7 +154,7 @@ public void updateRoomStatus(Long memberId, Long roomId, @Transactional public RoomUpdateInfoResponse getRoomUpdateInfo(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); List team = participantService.getTeam(roomId); return RoomMapper.toRoomUpdateInfoResponse(room, team); } @@ -170,7 +176,7 @@ public ReportResponse getRoomReport(Long roomId, Long memberId) { throw new ErrorException(UNAUTHORIZED_REPORT_READ); } - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); Long totalView = participantReader.countTotalView(roomId); Long totalQuestions = questionReader.countByRoomId(roomId); Long totalAnswerByQuestion = answerReader.countAnsweredQuestionsByRoomId(roomId); @@ -217,7 +223,7 @@ private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuesti public RoomJoinResponse subscribeRoom(Long roomId, Long memberId) { participantService.validateParticipant(memberId, roomId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); if (!room.getRoomStatus().canSubscribeRoom) { throw new ErrorException(UNAUTHORIZED_SUBSCRIBE_ROOM); } diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java index aeb3d4c..b0c727e 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java @@ -1,27 +1,14 @@ package com.oronaminc.join.emoji.service; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; -import com.oronaminc.join.answer.service.AnswerReader; -import com.oronaminc.join.config.TestQueryDslConfig; -import com.oronaminc.join.emoji.dao.EmojiRepository; -import com.oronaminc.join.emoji.domain.Emoji; -import com.oronaminc.join.emoji.domain.TargetType; -import com.oronaminc.join.emoji.dto.EmojiRequest; -import com.oronaminc.join.member.dao.MemberRepository; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.room.dao.RoomRepository; -import com.oronaminc.join.room.domain.Room; -import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.service.RoomReader; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +18,21 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.oronaminc.join.answer.service.AnswerReader; +import com.oronaminc.join.config.TestQueryDslConfig; +import com.oronaminc.join.emoji.dao.EmojiRepository; +import com.oronaminc.join.emoji.domain.Emoji; +import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.emoji.dto.EmojiRequest; +import com.oronaminc.join.member.dao.MemberRepository; +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.question.service.QuestionReader; +import com.oronaminc.join.room.dao.RoomRepository; +import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.room.domain.RoomStatus; +import com.oronaminc.join.room.service.RoomReader; + @DataJpaTest @Import({EmojiFacade.class, EmojiService.class, MemberReader.class, EmojiReader.class, RoomReader.class, QuestionReader.class, AnswerReader.class, TestQueryDslConfig.class}) @@ -118,7 +120,7 @@ void deleteEmoji_success_test() throws InterruptedException { Room.builder() .title("제목") .description("내용") - .secretCode("123456") + .secretCode("654321") .emojiCount(emojiCount) .participantLimit(0) .endedAt(LocalDateTime.now()) diff --git a/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java new file mode 100644 index 0000000..9c92108 --- /dev/null +++ b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java @@ -0,0 +1,108 @@ +package com.oronaminc.join.room.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import com.oronaminc.join.member.dao.MemberRepository; +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.participant.dao.ParticipantRepository; +import com.oronaminc.join.participant.domain.Participant; +import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.room.dao.RoomRepository; +import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.room.domain.RoomStatus; +import com.oronaminc.join.room.domain.RoomType; +import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; + +@SpringBootTest +@ActiveProfiles("test") +@EnableCaching +class RoomCacheTests { + @Autowired + private RoomService roomService; + + @Autowired + private RoomReader roomReader; + + @MockitoSpyBean + private RoomRepository roomRepository; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @BeforeEach + void setUp() { + Room room = Room.builder() + .title("Test Room") + .description("Test Description") + .roomStatus(RoomStatus.BEFORE_START) + .roomType(RoomType.PUBLIC) + .build(); + + roomRepository.save(room); + + Member member = Member.builder() + .build(); + + memberRepository.save(member); + + Participant participant = Participant.builder() + .room(room) + .member(member) + .participantType(ParticipantType.PRESENTER) + .build(); + + participantRepository.save(participant); + } + + @Test + void 캐시가_적용되어_두번째_조회는_DB_접근이_없어야_한다() { + Long roomId = 1L; + + Room room = roomReader.getCacheById(roomId); + + int repeat = 10; + for (int count = 0; count < repeat; count++) { + Room cacheRoom = roomReader.getCacheById(roomId); + assertThat(room).isSameAs(cacheRoom); + } + + verify(roomRepository, times(1)).findById(roomId); + + Cache roomCache = cacheManager.getCache("roomById"); + Room cached = roomCache.get(roomId, Room.class); + assertThat(cached).isNotNull(); + } + + @Test + void updateRoomStatus_호출시_캐시가_삭제되어야_한다() { + Long roomId = 1L; + Long memberId = 1L; + + roomReader.getCacheById(roomId); + + Room cachedBefore = cacheManager.getCache("roomById").get(roomId, Room.class); + assertThat(cachedBefore).isNotNull(); + + roomService.updateRoomStatus(memberId, roomId, new RoomUpdateStatusRequest(RoomStatus.STARTED)); + + Room cachedAfter = cacheManager.getCache("roomById").get(roomId, Room.class); + assertThat(cachedAfter).isNull(); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 4ea2014..469322b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -10,3 +10,14 @@ spring: hibernate: ddl-auto: create +cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: test-bucket + stack: + auto: false + credentials: + access-key: dummy + secret-key: dummy \ No newline at end of file From 657f97832e911a00f3781f7c0b6849dc04d7bb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:46:59 +0900 Subject: [PATCH 18/74] =?UTF-8?q?feat:=20cors=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/member/security/SecurityConfig.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index fd6861b..9babaab 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -23,6 +23,7 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) + .cors(withDefaults()) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/guest", @@ -49,4 +50,22 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins( + Arrays.asList( + "http://localhost:8080", + "http://localhost:3000" + ) + ); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } From 7e498b8d99caaeabbeda613a9c6a6a368a6cedec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:49:25 +0900 Subject: [PATCH 19/74] =?UTF-8?q?fix:=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/oronaminc/join/member/security/SecurityConfig.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 9babaab..82c7727 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -2,6 +2,9 @@ import static org.springframework.security.config.Customizer.*; +import java.util.Arrays; +import java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -9,6 +12,9 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.RequiredArgsConstructor; From b0469da4186379c2065c3de30d9e267b85ad8fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Mon, 21 Jul 2025 18:23:01 +0900 Subject: [PATCH 20/74] =?UTF-8?q?chore:=20=EC=9B=8C=ED=81=AC=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=88=98=EB=8F=99=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-workflow.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index 08f7cd1..996554e 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -4,7 +4,8 @@ on: push: branches: - release - + workflow_dispatch: + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -95,4 +96,4 @@ jobs: cd ${{ secrets.EC2_DEPLOY_DIR }} docker compose pull docker compose down - docker compose up -d \ No newline at end of file + docker compose up -d From bdcf47addd5aaf6b71aae6409d3bbf7500d120c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:57:48 +0900 Subject: [PATCH 21/74] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 --- .../join/member/dto/KakaoLoginRequest.java | 7 ++ .../join/member/dto/KakaoLoginResponse.java | 9 ++ .../join/member/dto/KakaoUserResponse.java | 11 ++ .../join/member/security/AuthController.java | 37 ++++++- .../join/member/security/AuthService.java | 104 ++++++++++++++---- .../join/member/security/SecurityConfig.java | 2 + .../join/member/util/MemberMapper.java | 18 +++ src/test/resources/application.yml | 22 ++++ 8 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java new file mode 100644 index 0000000..e6a99e8 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java @@ -0,0 +1,7 @@ +package com.oronaminc.join.member.dto; + +public record KakaoLoginRequest( + String code, + String state +) { +} diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java new file mode 100644 index 0000000..9e0ef7d --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java @@ -0,0 +1,9 @@ +package com.oronaminc.join.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record KakaoLoginResponse( + @Schema(description = "회원 id", example = "1001") + Long id +) { +} diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java b/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java new file mode 100644 index 0000000..0ce9106 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.member.dto; + +import lombok.Builder; + +@Builder +public record KakaoUserResponse( + String email, + String nickname, + String profileImageUrl +) { +} diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 85b9741..08e5d1f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -1,10 +1,5 @@ package com.oronaminc.join.member.security; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.tags.Tags; import java.util.List; import org.springframework.http.HttpStatus; @@ -24,8 +19,13 @@ import com.oronaminc.join.member.dto.GuestLoginRequest; import com.oronaminc.join.member.dto.GuestLoginResponse; +import com.oronaminc.join.member.dto.KakaoLoginRequest; +import com.oronaminc.join.member.dto.KakaoLoginResponse; import com.oronaminc.join.member.dto.SessionInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -40,6 +40,33 @@ public class AuthController { private final AuthService authService; + @Operation( + summary = "카카오 로그인", + description = "redirect url 에 포함된 파라미터의 code와 state를 입력해주세요. 이후 모든 요청에 세션 인증이 적용됩니다." + ) + @PostMapping("/kakao") + @ResponseStatus(HttpStatus.OK) + public KakaoLoginResponse kakaoLogin( + @RequestBody KakaoLoginRequest kakaoLoginRequest, + HttpServletRequest request + ) { + MemberDetails memberDetails = authService.kakaoLogin(kakaoLoginRequest.code()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + memberDetails, null, List.of(new SimpleGrantedAuthority(memberDetails.getRole())) + ); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + + return new KakaoLoginResponse(memberDetails.getId()); + } + @Operation( summary = "비회원 로그인", description = "닉네임을 입력하면 비회원 세션이 생성되고 인증이 설정됩니다. 이후 모든 요청에 세션 인증이 적용됩니다.", diff --git a/src/main/java/com/oronaminc/join/member/security/AuthService.java b/src/main/java/com/oronaminc/join/member/security/AuthService.java index ba1c2ad..3d64d9f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthService.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthService.java @@ -3,19 +3,26 @@ import static com.oronaminc.join.member.util.MemberMapper.*; import java.util.Map; -import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; import com.oronaminc.join.member.dao.MemberRepository; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.dto.KakaoUserResponse; import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.member.util.MemberMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,32 +34,91 @@ public class AuthService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; private final MemberReader memberReader; - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); - Map attributes = oAuth2User.getAttributes(); + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String TOKEN_URI = "https://kauth.kakao.com/oauth/token"; + private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirectUri; + + @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") + private String clientSecret; + + // @Override + // public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // OAuth2User oAuth2User = super.loadUser(userRequest); + // Map attributes = oAuth2User.getAttributes(); + // + // log.info("attributes :: " + attributes); + // + // Map kakaoAccount = (Map) attributes.get("kakao_account"); + // Map profile = (Map) kakaoAccount.get("profile"); + // + // + // Optional optionalMember = memberReader.findByEmail(kakaoAccount.get("email").toString()); + // + // Member member = optionalMember.orElseGet(() -> memberRepository.save(toKakaoMember(kakaoAccount, profile))); + // + // return toOAuth2MemberDetails(member); + // } - log.info("attributes :: " + attributes); + @Transactional + public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { + Member guest = toGuestMember(guestLoginRequest); - Map kakaoAccount = (Map) attributes.get("kakao_account"); - Map profile = (Map) kakaoAccount.get("profile"); + memberRepository.save(guest); + guest.registerGuest(); + return toGuestMemberDetails(guest); + } - Optional optionalMember = memberReader.findByEmail(kakaoAccount.get("email").toString()); + @Transactional + public MemberDetails kakaoLogin(String code) { + String accessToken = getAccessToken(code); + KakaoUserResponse kakaoUser = getUserInfo(accessToken); - Member member = optionalMember.orElseGet(() -> memberRepository.save(toKakaoMember(kakaoAccount, profile))); + Member member = memberRepository.findByEmail(kakaoUser.email()) + .orElseGet(() -> memberRepository.save(MemberMapper.toNewKakaoMember(kakaoUser))); return toOAuth2MemberDetails(member); } - @Transactional - public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { - Member guest = toGuestMember(guestLoginRequest); + private String getAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - memberRepository.save(guest); - guest.registerGuest(); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("redirect_uri", redirectUri); + params.add("code", code); + params.add("client_secret", clientSecret); - return toGuestMemberDetails(guest); + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.postForEntity(TOKEN_URI, request, Map.class); + + return (String) response.getBody().get("access_token"); } + private KakaoUserResponse getUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(USER_INFO_URI, HttpMethod.GET, entity, Map.class); + + Map attributes = response.getBody(); + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return MemberMapper.toKakaoUserResponse(kakaoAccount, profile); + } } diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index fd6861b..582dac7 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -23,9 +23,11 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/guest", + "/api/auth/kakao", "/login" ) .anonymous() diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java index 673fa6d..7835d9b 100644 --- a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -5,6 +5,7 @@ import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.dto.KakaoUserResponse; import com.oronaminc.join.member.security.MemberDetails; import lombok.AccessLevel; @@ -47,4 +48,21 @@ public static Member toKakaoMember(Map kakaoAccount, Map kakaoAccount, Map profile) { + return KakaoUserResponse.builder() + .email((String) kakaoAccount.get("email")) + .nickname((String) profile.get("nickname")) + .profileImageUrl((String) profile.get("profile_image_url")) + .build(); + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 469322b..c1c6fd1 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -10,6 +10,28 @@ spring: hibernate: ddl-auto: create + security: + oauth2: + client: + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + registration: + kakao: + client-name: Kakao + client-id: KAKAO_CLIENT_ID + client-secret: KAKAO_CLIENT_SECRET + redirect-uri: KAKAO_REDIRECT_URI + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - profile_nickname + - profile_image + - account_email + cloud: aws: region: From 262293114e28620dd1a3515391d32788afe008dc Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:15:46 +0900 Subject: [PATCH 22/74] =?UTF-8?q?refactor:=20Presigned=20URL=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=9A=A9,=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 --- .../join/document/api/DocumentController.java | 4 +++- .../document/service/DocumentService.java | 4 ++-- .../join/infra/service/S3Service.java | 23 ++++++++++++++++++- .../join/room/service/RoomService.java | 2 +- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/oronaminc/join/document/api/DocumentController.java b/src/main/java/com/oronaminc/join/document/api/DocumentController.java index 57dccbd..86dc696 100644 --- a/src/main/java/com/oronaminc/join/document/api/DocumentController.java +++ b/src/main/java/com/oronaminc/join/document/api/DocumentController.java @@ -7,12 +7,14 @@ import com.oronaminc.join.member.security.MemberDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "발표자료") @RestController @RequiredArgsConstructor @RequestMapping("/api/documents") @@ -37,6 +39,6 @@ public DocumentResponse generatePresignedUrl( @AuthenticationPrincipal MemberDetails memberDetails ) { String memberRole = memberDetails.getRole(); - return documentService.generatePresignedUrl(documentRequest, memberRole); + return documentService.generateUploadPresignedUrl(documentRequest, memberRole); } } diff --git a/src/main/java/com/oronaminc/join/document/service/DocumentService.java b/src/main/java/com/oronaminc/join/document/service/DocumentService.java index 6ada500..9216712 100644 --- a/src/main/java/com/oronaminc/join/document/service/DocumentService.java +++ b/src/main/java/com/oronaminc/join/document/service/DocumentService.java @@ -37,7 +37,7 @@ public void deleteByRoomId(Long roomId) { documentRepository.deleteByRoomId(roomId); } - public DocumentResponse generatePresignedUrl(DocumentRequest request, String memberRole) { + public DocumentResponse generateUploadPresignedUrl(DocumentRequest request, String memberRole) { if (!memberRole.equals(MemberType.MEMBER.name())) { throw new ErrorException(ErrorCode.UNAUTHORIZED_MEMBER); } @@ -52,7 +52,7 @@ public DocumentResponse generatePresignedUrl(DocumentRequest request, String mem String uuid = UUID.randomUUID().toString(); String objectKey = "temp/" + uuid + extension; - String presignedUrl = s3Service.generatePresignedUrl(objectKey); + String presignedUrl = s3Service.generateUploadPresignedUrl(objectKey); return new DocumentResponse(presignedUrl, objectKey); } diff --git a/src/main/java/com/oronaminc/join/infra/service/S3Service.java b/src/main/java/com/oronaminc/join/infra/service/S3Service.java index 3bc2261..f5ac28d 100644 --- a/src/main/java/com/oronaminc/join/infra/service/S3Service.java +++ b/src/main/java/com/oronaminc/join/infra/service/S3Service.java @@ -11,6 +11,8 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.time.Duration; @@ -25,7 +27,8 @@ public class S3Service { @Value("${cloud.aws.s3.bucket}") private String bucket; - public String generatePresignedUrl(String key) { + // 조회용 + public String generateGetPresignedUrl(String key) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucket) .key(key) @@ -41,6 +44,24 @@ public String generatePresignedUrl(String key) { return presignedRequest.url().toString(); } + // 업로드용 + public String generateUploadPresignedUrl(String key) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .contentType("application/pdf") + .key(key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(3)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + + return presignedRequest.url().toString(); + } + public void deleteFile(String key) { try { if (isFileExist(key)) { diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index e28e429..9f04704 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -98,7 +98,7 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { List team = participantService.getTeam(roomId); Document document = documentReader.getByRoomId(roomId); int participantCount = currentParticipantManager.getRoomParticipants(roomId).size(); - String presignedUrl = s3Service.generatePresignedUrl(document.getFileUrl()); + String presignedUrl = s3Service.generateGetPresignedUrl(document.getFileUrl()); return RoomMapper.toRoomDetailResponse(room, presenter, team, presignedUrl, memberId, participantCount); } From f777be05c1a0dc29e9af516f5880731bbf0b5847 Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:15:30 +0900 Subject: [PATCH 23/74] =?UTF-8?q?release:=20=EA=B0=9C=EB=B0=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=BD=94=EB=93=9C=20release=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=91=ED=95=A9=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 --------- Co-authored-by: 김건우 <96411818+rjswjddn@users.noreply.github.com> Co-authored-by: 김건우 --- .github/workflows/release-workflow.yml | 2 +- .../join/document/api/DocumentController.java | 4 +- .../document/service/DocumentService.java | 4 +- .../join/infra/service/S3Service.java | 23 +++- .../join/member/dto/KakaoLoginRequest.java | 7 ++ .../join/member/dto/KakaoLoginResponse.java | 9 ++ .../join/member/dto/KakaoUserResponse.java | 11 ++ .../join/member/security/AuthController.java | 37 ++++++- .../join/member/security/AuthService.java | 104 ++++++++++++++---- .../join/member/security/SecurityConfig.java | 27 +---- .../join/member/util/MemberMapper.java | 18 +++ .../join/room/service/RoomService.java | 2 +- src/test/resources/application.yml | 22 ++++ 13 files changed, 215 insertions(+), 55 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index 996554e..a4fcfc8 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -5,7 +5,7 @@ on: branches: - release workflow_dispatch: - + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} diff --git a/src/main/java/com/oronaminc/join/document/api/DocumentController.java b/src/main/java/com/oronaminc/join/document/api/DocumentController.java index 57dccbd..86dc696 100644 --- a/src/main/java/com/oronaminc/join/document/api/DocumentController.java +++ b/src/main/java/com/oronaminc/join/document/api/DocumentController.java @@ -7,12 +7,14 @@ import com.oronaminc.join.member.security.MemberDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "발표자료") @RestController @RequiredArgsConstructor @RequestMapping("/api/documents") @@ -37,6 +39,6 @@ public DocumentResponse generatePresignedUrl( @AuthenticationPrincipal MemberDetails memberDetails ) { String memberRole = memberDetails.getRole(); - return documentService.generatePresignedUrl(documentRequest, memberRole); + return documentService.generateUploadPresignedUrl(documentRequest, memberRole); } } diff --git a/src/main/java/com/oronaminc/join/document/service/DocumentService.java b/src/main/java/com/oronaminc/join/document/service/DocumentService.java index 6ada500..9216712 100644 --- a/src/main/java/com/oronaminc/join/document/service/DocumentService.java +++ b/src/main/java/com/oronaminc/join/document/service/DocumentService.java @@ -37,7 +37,7 @@ public void deleteByRoomId(Long roomId) { documentRepository.deleteByRoomId(roomId); } - public DocumentResponse generatePresignedUrl(DocumentRequest request, String memberRole) { + public DocumentResponse generateUploadPresignedUrl(DocumentRequest request, String memberRole) { if (!memberRole.equals(MemberType.MEMBER.name())) { throw new ErrorException(ErrorCode.UNAUTHORIZED_MEMBER); } @@ -52,7 +52,7 @@ public DocumentResponse generatePresignedUrl(DocumentRequest request, String mem String uuid = UUID.randomUUID().toString(); String objectKey = "temp/" + uuid + extension; - String presignedUrl = s3Service.generatePresignedUrl(objectKey); + String presignedUrl = s3Service.generateUploadPresignedUrl(objectKey); return new DocumentResponse(presignedUrl, objectKey); } diff --git a/src/main/java/com/oronaminc/join/infra/service/S3Service.java b/src/main/java/com/oronaminc/join/infra/service/S3Service.java index 3bc2261..f5ac28d 100644 --- a/src/main/java/com/oronaminc/join/infra/service/S3Service.java +++ b/src/main/java/com/oronaminc/join/infra/service/S3Service.java @@ -11,6 +11,8 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.time.Duration; @@ -25,7 +27,8 @@ public class S3Service { @Value("${cloud.aws.s3.bucket}") private String bucket; - public String generatePresignedUrl(String key) { + // 조회용 + public String generateGetPresignedUrl(String key) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucket) .key(key) @@ -41,6 +44,24 @@ public String generatePresignedUrl(String key) { return presignedRequest.url().toString(); } + // 업로드용 + public String generateUploadPresignedUrl(String key) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .contentType("application/pdf") + .key(key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(3)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + + return presignedRequest.url().toString(); + } + public void deleteFile(String key) { try { if (isFileExist(key)) { diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java new file mode 100644 index 0000000..e6a99e8 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java @@ -0,0 +1,7 @@ +package com.oronaminc.join.member.dto; + +public record KakaoLoginRequest( + String code, + String state +) { +} diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java new file mode 100644 index 0000000..9e0ef7d --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java @@ -0,0 +1,9 @@ +package com.oronaminc.join.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record KakaoLoginResponse( + @Schema(description = "회원 id", example = "1001") + Long id +) { +} diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java b/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java new file mode 100644 index 0000000..0ce9106 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.member.dto; + +import lombok.Builder; + +@Builder +public record KakaoUserResponse( + String email, + String nickname, + String profileImageUrl +) { +} diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 85b9741..08e5d1f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -1,10 +1,5 @@ package com.oronaminc.join.member.security; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.tags.Tags; import java.util.List; import org.springframework.http.HttpStatus; @@ -24,8 +19,13 @@ import com.oronaminc.join.member.dto.GuestLoginRequest; import com.oronaminc.join.member.dto.GuestLoginResponse; +import com.oronaminc.join.member.dto.KakaoLoginRequest; +import com.oronaminc.join.member.dto.KakaoLoginResponse; import com.oronaminc.join.member.dto.SessionInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -40,6 +40,33 @@ public class AuthController { private final AuthService authService; + @Operation( + summary = "카카오 로그인", + description = "redirect url 에 포함된 파라미터의 code와 state를 입력해주세요. 이후 모든 요청에 세션 인증이 적용됩니다." + ) + @PostMapping("/kakao") + @ResponseStatus(HttpStatus.OK) + public KakaoLoginResponse kakaoLogin( + @RequestBody KakaoLoginRequest kakaoLoginRequest, + HttpServletRequest request + ) { + MemberDetails memberDetails = authService.kakaoLogin(kakaoLoginRequest.code()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + memberDetails, null, List.of(new SimpleGrantedAuthority(memberDetails.getRole())) + ); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + + return new KakaoLoginResponse(memberDetails.getId()); + } + @Operation( summary = "비회원 로그인", description = "닉네임을 입력하면 비회원 세션이 생성되고 인증이 설정됩니다. 이후 모든 요청에 세션 인증이 적용됩니다.", diff --git a/src/main/java/com/oronaminc/join/member/security/AuthService.java b/src/main/java/com/oronaminc/join/member/security/AuthService.java index ba1c2ad..3d64d9f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthService.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthService.java @@ -3,19 +3,26 @@ import static com.oronaminc.join.member.util.MemberMapper.*; import java.util.Map; -import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; import com.oronaminc.join.member.dao.MemberRepository; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.dto.KakaoUserResponse; import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.member.util.MemberMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,32 +34,91 @@ public class AuthService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; private final MemberReader memberReader; - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); - Map attributes = oAuth2User.getAttributes(); + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String TOKEN_URI = "https://kauth.kakao.com/oauth/token"; + private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirectUri; + + @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") + private String clientSecret; + + // @Override + // public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // OAuth2User oAuth2User = super.loadUser(userRequest); + // Map attributes = oAuth2User.getAttributes(); + // + // log.info("attributes :: " + attributes); + // + // Map kakaoAccount = (Map) attributes.get("kakao_account"); + // Map profile = (Map) kakaoAccount.get("profile"); + // + // + // Optional optionalMember = memberReader.findByEmail(kakaoAccount.get("email").toString()); + // + // Member member = optionalMember.orElseGet(() -> memberRepository.save(toKakaoMember(kakaoAccount, profile))); + // + // return toOAuth2MemberDetails(member); + // } - log.info("attributes :: " + attributes); + @Transactional + public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { + Member guest = toGuestMember(guestLoginRequest); - Map kakaoAccount = (Map) attributes.get("kakao_account"); - Map profile = (Map) kakaoAccount.get("profile"); + memberRepository.save(guest); + guest.registerGuest(); + return toGuestMemberDetails(guest); + } - Optional optionalMember = memberReader.findByEmail(kakaoAccount.get("email").toString()); + @Transactional + public MemberDetails kakaoLogin(String code) { + String accessToken = getAccessToken(code); + KakaoUserResponse kakaoUser = getUserInfo(accessToken); - Member member = optionalMember.orElseGet(() -> memberRepository.save(toKakaoMember(kakaoAccount, profile))); + Member member = memberRepository.findByEmail(kakaoUser.email()) + .orElseGet(() -> memberRepository.save(MemberMapper.toNewKakaoMember(kakaoUser))); return toOAuth2MemberDetails(member); } - @Transactional - public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { - Member guest = toGuestMember(guestLoginRequest); + private String getAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - memberRepository.save(guest); - guest.registerGuest(); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("redirect_uri", redirectUri); + params.add("code", code); + params.add("client_secret", clientSecret); - return toGuestMemberDetails(guest); + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.postForEntity(TOKEN_URI, request, Map.class); + + return (String) response.getBody().get("access_token"); } + private KakaoUserResponse getUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(USER_INFO_URI, HttpMethod.GET, entity, Map.class); + + Map attributes = response.getBody(); + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return MemberMapper.toKakaoUserResponse(kakaoAccount, profile); + } } diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 82c7727..582dac7 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -2,9 +2,6 @@ import static org.springframework.security.config.Customizer.*; -import java.util.Arrays; -import java.util.List; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -12,9 +9,6 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.RequiredArgsConstructor; @@ -29,10 +23,11 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) - .cors(withDefaults()) + .cors(cors -> cors.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/guest", + "/api/auth/kakao", "/login" ) .anonymous() @@ -56,22 +51,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins( - Arrays.asList( - "http://localhost:8080", - "http://localhost:3000" - ) - ); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } } diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java index 673fa6d..7835d9b 100644 --- a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -5,6 +5,7 @@ import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.dto.KakaoUserResponse; import com.oronaminc.join.member.security.MemberDetails; import lombok.AccessLevel; @@ -47,4 +48,21 @@ public static Member toKakaoMember(Map kakaoAccount, Map kakaoAccount, Map profile) { + return KakaoUserResponse.builder() + .email((String) kakaoAccount.get("email")) + .nickname((String) profile.get("nickname")) + .profileImageUrl((String) profile.get("profile_image_url")) + .build(); + } } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index e28e429..9f04704 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -98,7 +98,7 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { List team = participantService.getTeam(roomId); Document document = documentReader.getByRoomId(roomId); int participantCount = currentParticipantManager.getRoomParticipants(roomId).size(); - String presignedUrl = s3Service.generatePresignedUrl(document.getFileUrl()); + String presignedUrl = s3Service.generateGetPresignedUrl(document.getFileUrl()); return RoomMapper.toRoomDetailResponse(room, presenter, team, presignedUrl, memberId, participantCount); } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 469322b..c1c6fd1 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -10,6 +10,28 @@ spring: hibernate: ddl-auto: create + security: + oauth2: + client: + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + registration: + kakao: + client-name: Kakao + client-id: KAKAO_CLIENT_ID + client-secret: KAKAO_CLIENT_SECRET + redirect-uri: KAKAO_REDIRECT_URI + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - profile_nickname + - profile_image + - account_email + cloud: aws: region: From 12c801a96156d3167ad037277656990f929e1b47 Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:20:44 +0900 Subject: [PATCH 24/74] =?UTF-8?q?refactor:=20profiles=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=20=EC=A3=BC=EC=9E=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-workflow.yml | 2 ++ Dockerfile | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index a4fcfc8..aea75e9 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -78,6 +78,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + SPRING_PROFILES_ACTIVE=dev deploy: name: EC2 자동 배포 diff --git a/Dockerfile b/Dockerfile index 94b6ec4..98af97d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,5 +20,8 @@ WORKDIR /app COPY --from=builder /libs/build/libs/*.jar app.jar -ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar", "app.jar"] +ARG SPRING_PROFILES_ACTIVE +ENV SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "app.jar"] From 65e05fd9007ea9a3568400c096b80ff2e61b9ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:30:20 +0900 Subject: [PATCH 25/74] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81,=20=EB=B0=A9=20=EC=B0=B8=EA=B0=80=EC=9E=90?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 --- build.gradle | 1 + .../global/config/RestTemplateConfig.java | 38 +++++++++++++++++++ .../join/room/api/RoomController.java | 2 +- .../session/CurrentParticipantManager.java | 27 +++++++++++-- 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java diff --git a/build.gradle b/build.gradle index 7945a4a..828451c 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,7 @@ dependencies { // caffeine implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'org.springframework.retry:spring-retry:2.0.12' } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java b/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..1e98591 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java @@ -0,0 +1,38 @@ +package com.oronaminc.join.global.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestTemplate; + +@Configuration +class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .additionalInterceptors(clientHttpRequestInterceptor()) + .build(); + } + + // 3번 재시도 + public ClientHttpRequestInterceptor clientHttpRequestInterceptor() { + return (request, body, execution) -> { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(3)); + try { + return retryTemplate.execute(context -> execution.execute(request, body)); + } catch (Throwable throwable) { + throw new RuntimeException(throwable); + } + }; + } + +} diff --git a/src/main/java/com/oronaminc/join/room/api/RoomController.java b/src/main/java/com/oronaminc/join/room/api/RoomController.java index ff58993..d64cc68 100644 --- a/src/main/java/com/oronaminc/join/room/api/RoomController.java +++ b/src/main/java/com/oronaminc/join/room/api/RoomController.java @@ -63,7 +63,7 @@ public CreateRoomResponse createRoom( description = "비밀코드를 통해 해당 발표방에 참가자로 등록합니다. 시작 전 상태이면 참가할 수 없습니다.", security = @SecurityRequirement(name = "sessionAuth") ) - @GetMapping("/code") + @PostMapping("/code") @ResponseStatus(HttpStatus.OK) public JoinRoomResponse joinRoom( @RequestBody JoinRoomRequest joinRoomRequest, diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java index 3716745..d976c9c 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java @@ -23,17 +23,36 @@ public void createRoom(Long roomId) { roomParticipants.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()); } + // public void addParticipant(Long roomId, Long memberId, int limit) { + // Set participants = getRoomParticipants(roomId); + // + // if (participants.contains(memberId)) return; + // + // synchronized (participants) { + // if (participants.size() >= limit) { + // throw new ErrorException(UNAUTHORIZED_LIMIT_PARTICIPANT); + // } + // participants.add(memberId); + // } + // } + public void addParticipant(Long roomId, Long memberId, int limit) { - Set participants = getRoomParticipants(roomId); + roomParticipants.compute(roomId, (id, participants) -> { + participants = getRoomParticipants(roomId); - if (participants.contains(memberId)) return; + // 중복 참가자일 경우 그대로 반환 (변화 없음) + if (participants.contains(memberId)) { + return participants; + } - synchronized (participants) { + // 인원 초과 시 예외 발생 if (participants.size() >= limit) { throw new ErrorException(UNAUTHORIZED_LIMIT_PARTICIPANT); } + participants.add(memberId); - } + return participants; + }); } public void removeParticipant(Long memberId, Long roomId) { From dfb5aecf7891c0336152abbfb90b112827e25b06 Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:58:17 +0900 Subject: [PATCH 26/74] =?UTF-8?q?release:=20=EA=B0=9C=EB=B0=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=BD=94=EB=93=9C=20release=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=91=ED=95=A9=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 --------- Co-authored-by: 김건우 <96411818+rjswjddn@users.noreply.github.com> Co-authored-by: 김건우 --- .github/workflows/release-workflow.yml | 2 + Dockerfile | 5 ++- build.gradle | 1 + .../global/config/RestTemplateConfig.java | 38 +++++++++++++++++++ .../join/room/api/RoomController.java | 2 +- .../session/CurrentParticipantManager.java | 27 +++++++++++-- 6 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index a4fcfc8..aea75e9 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -78,6 +78,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + SPRING_PROFILES_ACTIVE=dev deploy: name: EC2 자동 배포 diff --git a/Dockerfile b/Dockerfile index 94b6ec4..98af97d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,5 +20,8 @@ WORKDIR /app COPY --from=builder /libs/build/libs/*.jar app.jar -ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar", "app.jar"] +ARG SPRING_PROFILES_ACTIVE +ENV SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index 7945a4a..828451c 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,7 @@ dependencies { // caffeine implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'org.springframework.retry:spring-retry:2.0.12' } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java b/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..1e98591 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java @@ -0,0 +1,38 @@ +package com.oronaminc.join.global.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestTemplate; + +@Configuration +class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .additionalInterceptors(clientHttpRequestInterceptor()) + .build(); + } + + // 3번 재시도 + public ClientHttpRequestInterceptor clientHttpRequestInterceptor() { + return (request, body, execution) -> { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(3)); + try { + return retryTemplate.execute(context -> execution.execute(request, body)); + } catch (Throwable throwable) { + throw new RuntimeException(throwable); + } + }; + } + +} diff --git a/src/main/java/com/oronaminc/join/room/api/RoomController.java b/src/main/java/com/oronaminc/join/room/api/RoomController.java index ff58993..d64cc68 100644 --- a/src/main/java/com/oronaminc/join/room/api/RoomController.java +++ b/src/main/java/com/oronaminc/join/room/api/RoomController.java @@ -63,7 +63,7 @@ public CreateRoomResponse createRoom( description = "비밀코드를 통해 해당 발표방에 참가자로 등록합니다. 시작 전 상태이면 참가할 수 없습니다.", security = @SecurityRequirement(name = "sessionAuth") ) - @GetMapping("/code") + @PostMapping("/code") @ResponseStatus(HttpStatus.OK) public JoinRoomResponse joinRoom( @RequestBody JoinRoomRequest joinRoomRequest, diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java index 3716745..d976c9c 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java @@ -23,17 +23,36 @@ public void createRoom(Long roomId) { roomParticipants.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()); } + // public void addParticipant(Long roomId, Long memberId, int limit) { + // Set participants = getRoomParticipants(roomId); + // + // if (participants.contains(memberId)) return; + // + // synchronized (participants) { + // if (participants.size() >= limit) { + // throw new ErrorException(UNAUTHORIZED_LIMIT_PARTICIPANT); + // } + // participants.add(memberId); + // } + // } + public void addParticipant(Long roomId, Long memberId, int limit) { - Set participants = getRoomParticipants(roomId); + roomParticipants.compute(roomId, (id, participants) -> { + participants = getRoomParticipants(roomId); - if (participants.contains(memberId)) return; + // 중복 참가자일 경우 그대로 반환 (변화 없음) + if (participants.contains(memberId)) { + return participants; + } - synchronized (participants) { + // 인원 초과 시 예외 발생 if (participants.size() >= limit) { throw new ErrorException(UNAUTHORIZED_LIMIT_PARTICIPANT); } + participants.add(memberId); - } + return participants; + }); } public void removeParticipant(Long memberId, Long roomId) { From a0ef2c6f9ba1bed49c4fdb01dd554e117031c667 Mon Sep 17 00:00:00 2001 From: chcch529 <146617430+chcch529@users.noreply.github.com> Date: Sat, 26 Jul 2025 17:37:02 +0900 Subject: [PATCH 27/74] =?UTF-8?q?refactor,fix:=202=ED=9A=8C=EC=B0=A8=20?= =?UTF-8?q?=EB=A9=98=ED=86=A0=EB=A7=81=20=EA=B8=B0=EB=B0=98=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../join/answer/dto/AnswerCreateResponse.java | 3 +- .../join/answer/dto/AnswerDeleteResponse.java | 3 +- .../join/answer/dto/AnswerUpdateResponse.java | 3 +- .../join/answer/mapper/AnswerMapper.java | 5 ++- .../join/emoji/dto/EmojiResponse.java | 3 +- .../join/emoji/service/EmojiService.java | 5 ++- .../join/global/exception/ErrorCode.java | 3 +- .../join/global/exception/ErrorException.java | 18 ++++++++- .../global/exception/ExceptionAdvice.java | 2 +- .../dao/ParticipantRepository.java | 40 +++++++++---------- .../join/question/api/QuestionController.java | 3 +- .../question/dto/QuestionCreateResponse.java | 3 +- .../question/dto/QuestionDeleteResponse.java | 3 +- .../question/dto/QuestionUpdateResponse.java | 3 +- .../question/service/QuestionService.java | 14 ++++--- .../join/question/util/QuestionMapper.java | 7 ++-- .../join/room/util/CodeGenerator.java | 13 ++---- .../api/AnswerWebsocketController.java | 3 +- .../join/websocket/common/EventType.java | 5 +++ .../CurrentParticipantEventHandler.java | 5 ++- .../join/emoji/service/EmojiServiceTests.java | 13 +++--- 22 files changed, 96 insertions(+), 63 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/common/EventType.java diff --git a/build.gradle b/build.gradle index 828451c..3fb9bea 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,8 @@ dependencies { // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' + // commons-lang3 + implementation 'org.apache.commons:commons-lang3:3.18.0' // caffeine implementation 'com.github.ben-manes.caffeine:caffeine' diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java index 191ab18..c8e8c88 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java @@ -1,6 +1,7 @@ package com.oronaminc.join.answer.dto; import com.oronaminc.join.global.dto.WriterDto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -13,7 +14,7 @@ public record AnswerCreateResponse( @Schema(description = "답변이 생성될 질문 ID") Long questionId, @Schema(description = "답변 생성/삭제/수정 상태", example = "CREATE") - String event, + EventType event, @Schema(description = "답변 ID", example = "11") Long answerId, @Schema(description = "답변 내용", example = "답변입니다.") diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java index d8f4f64..36b5c9b 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.answer.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "답변 삭제 응답 DTO") public record AnswerDeleteResponse( Long answerId, @Schema(description = "삭제 이벤트", example = "DELETE") - String event + EventType event ) { } diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java index 3248b78..11f3835 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java @@ -1,5 +1,6 @@ package com.oronaminc.join.answer.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -8,7 +9,7 @@ public record AnswerUpdateResponse( Long answerId, @Schema(description = "수정 이벤트", example = "UPDATE") - String event, + EventType event, @Schema(description = "수정된 내용", example = "수정된 답변입니다.") String content diff --git a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java index 95dace3..0d27485 100644 --- a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java @@ -8,6 +8,7 @@ import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; +import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -17,7 +18,7 @@ public class AnswerMapper { public static AnswerCreateResponse toAnswerCreateResponse(Answer answer) { return AnswerCreateResponse.builder() .questionId(answer.getQuestion().getId()) - .event("CREATE") + .event(EventType.CREATE) .answerId(answer.getId()) .content(answer.getContent()) .emojiCount(0) @@ -51,7 +52,7 @@ public static Answer toEntity(Question question, Member member, AnswerRequest re public static AnswerUpdateResponse toAnswerUpdateResponse(Answer answer) { return AnswerUpdateResponse.builder() .answerId(answer.getId()) - .event("UPDATE") + .event(EventType.UPDATE) .content(answer.getContent()) .build(); } diff --git a/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java b/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java index eda6d68..99fc8a5 100644 --- a/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java +++ b/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.emoji.dto; import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "발표방/질문/답변 공감 생성/삭제 응답 DTO") public record EmojiResponse( @Schema(description = "이벤트 타입 (CREATE, DELETE)", example = "CREATE") - String event, + EventType event, @Schema(description = "공감 대상 타입 (ROOM, QUESTION, ANSWER)", example = "ROOM") TargetType targetType, @Schema(description = "공감 대상 ID", example = "1") diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java index 8bdc68b..4b20cae 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java @@ -11,6 +11,7 @@ import com.oronaminc.join.member.service.MemberReader; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.service.RoomReader; +import com.oronaminc.join.websocket.common.EventType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,7 +47,7 @@ public EmojiResponse createEmoji(Long memberId, EmojiRequest emojiRequest) { emojiCount = incrementEmojiCount(targetType, targetId); - return new EmojiResponse("CREATE", targetType, targetId, emojiCount); + return new EmojiResponse(EventType.CREATE, targetType, targetId, emojiCount); } @@ -62,7 +63,7 @@ public EmojiResponse deleteEmoji(Long memberId, EmojiRequest emojiRequest) { ); emojiCount = decrementEmojiCount(targetType, targetId); - return new EmojiResponse("DELETE", targetType, targetId, emojiCount); + return new EmojiResponse(EventType.DELETE, targetType, targetId, emojiCount); } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 5fc9cd7..865658b 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -31,7 +31,6 @@ public enum ErrorCode { UNAUTHORIZED_LIMIT_PARTICIPANT("PARTICIPANT-005", "인원이 가득 차 참가할 수 없습니다.", UNAUTHORIZED), UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-005", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), - FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), MOVEMENT_FILE_FAILED("FILE-003", "파일 이동이 실패하였습니다.", INTERNAL_SERVER_ERROR), @@ -51,7 +50,6 @@ public enum ErrorCode { UNAUTHORIZED_DELETE_ANSWER("ANSWER-005", "작성자 혹은 팀원, 발표자가 아니면 해당 댓글을 삭제할 수 없습니다.", UNAUTHORIZED), TOO_MANY_REQUESTS_ANSWER("ANSWER-006", "잠시 후 다시 시도해주세요.", UNAUTHORIZED), - ACCESS_DENIED_SESSION("SESSION-1201", "접근 권한이 없습니다.", FORBIDDEN), NOT_FOUND_SESSION("SESSION-1202", "세션이 유효하지 않습니다.", UNAUTHORIZED), EXPIRED_SESSION("SESSION-1203", "세션이 만료되었습니다.", UNAUTHORIZED), @@ -62,6 +60,7 @@ public enum ErrorCode { SOCKET_BAD_REQUEST_PATH("SOCKET-1002", "경로가 유효하지 않습니다.", BAD_REQUEST), SOCKET_BAD_REQUEST_MEMBER("SOCKET-1003", "회원이 유효하지 않습니다.", BAD_REQUEST), + STOMP_INVALID_DESTINATION("STOMP-001", "경로가 유효하지 않습니다.", BAD_REQUEST), CONFLICT_EMOJI("EMOJI-001", "공감 처리 중 충돌이 발생했습니다.", CONFLICT), NOT_FOUND_EMOJI("EMOJI-002", "해당 이모지가 존재하지 않습니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorException.java b/src/main/java/com/oronaminc/join/global/exception/ErrorException.java index 979f2c1..76828f7 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorException.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorException.java @@ -1,5 +1,7 @@ package com.oronaminc.join.global.exception; +import com.oronaminc.join.global.util.StringUtil; +import java.text.MessageFormat; import lombok.AllArgsConstructor; import lombok.Getter; @@ -8,5 +10,19 @@ public class ErrorException extends RuntimeException { private final ErrorCode errorCode; + private final String errorMessage; -} + public ErrorException(ErrorCode errorCode) { + this(errorCode, null); + } + + public static ErrorException of(ErrorCode errorCode, String message, Object... args) { + String errorMessage = createMessage(message, args); + return new ErrorException(errorCode, errorMessage); + } + + public static String createMessage(String message, Object... args) { + return StringUtil.format(message, args); + } + +} \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java index c166075..3572740 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java +++ b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java @@ -18,7 +18,7 @@ public class ExceptionAdvice { public ResponseEntity handleErrorException(ErrorException ex) { ErrorCode errorCode = ex.getErrorCode(); - log.error(errorCode.getMessage(), ex); + log.error("ErrorCode: {}, ErrorMessage: {}", ex.getErrorCode(), ex.getErrorMessage()); HttpStatus httpStatus = switch (errorCode.getErrorStatus()) { case NOT_FOUND -> HttpStatus.NOT_FOUND; diff --git a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java index 2b86ccc..6f200f8 100644 --- a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java +++ b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java @@ -15,11 +15,11 @@ public interface ParticipantRepository extends JpaRepository { - @Query( - "SELECT COUNT(p) > 0 " + - "FROM Participant p " + - "WHERE p.room.id = :roomId AND p.member.id = :memberId" - ) + @Query(""" + select COUNT(p) > 0 + from Participant p + where p.room.id = :roomId and p.member.id = :memberId + """) boolean existsByRoomIdAndMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); Optional findByRoomIdAndParticipantType(Long roomId, ParticipantType participantType); @@ -30,7 +30,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p where p.member.id = :memberId group by p.participantType - """) + """) List countByMemberIdGroupByParticipantType(Long memberId); @Query(""" @@ -38,7 +38,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p join fetch p.room where p.member.id = :memberId - """) + """) Page findByMemberId(Long memberId, Pageable pageable); @Query(""" @@ -46,7 +46,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p join fetch p.room where p.member.id = :memberId and p.participantType = :pType - """) + """) Page findByMemberIdAndParticipantType(Long memberId, ParticipantType pType, Pageable pageable); @@ -55,30 +55,30 @@ Page findByMemberIdAndParticipantType(Long memberId, ParticipantTyp from Participant p join fetch p.room where p.member.id = :memberId and p.participantType != :pType - """) + """) Page findByMemberIdAndParticipantTypeNot(Long memberId, ParticipantType pType, Pageable pageable); Optional findByRoomIdAndMemberId( @Param("roomId") Long roomId, @Param("memberId") Long memberId ); @Query(""" - SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END - FROM Participant p - WHERE p.room.id = :roomId - AND p.member.id = :memberId - AND (p.participantType = com.oronaminc.join.participant.domain.ParticipantType.PRESENTER - OR p.participantType = com.oronaminc.join.participant.domain.ParticipantType.TEAM) + select case when COUNT(p) > 0 then true else false end + from Participant p + where p.room.id = :roomId + and p.member.id = :memberId + and (p.participantType = com.oronaminc.join.participant.domain.ParticipantType.PRESENTER + or p.participantType = com.oronaminc.join.participant.domain.ParticipantType.TEAM) """) boolean existsPresenterOrTeamByMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); void deleteByRoomId(Long roomId); @Query(value = """ - SELECT COUNT(*) - FROM participant - WHERE room_id = :roomId - AND exited_at IS NOT NULL - AND TIMESTAMPDIFF(SECOND, created_at, exited_at) >= 30 + select COUNT(*) + from participant + where room_id = :roomId + and exited_at is not null + and TIMESTAMPDIFF(SECOND, created_at, exited_at) >= 30 """, nativeQuery = true) Long countParticipantsStayedOver30Seconds(@Param("roomId") Long roomId); } \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/question/api/QuestionController.java b/src/main/java/com/oronaminc/join/question/api/QuestionController.java index 2d183ee..901cfb7 100644 --- a/src/main/java/com/oronaminc/join/question/api/QuestionController.java +++ b/src/main/java/com/oronaminc/join/question/api/QuestionController.java @@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -30,7 +31,7 @@ public ResponseEntity getQuestions( @RequestParam(required = false) Long lastEmojiCount, @RequestParam(defaultValue = "10") int size, @RequestParam Long memberId, - @RequestParam Long roomId + @PathVariable Long roomId ) { Slice result = questionService.getQuestions( sort, lastId, lastEmojiCount, size, memberId, roomId diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java index 6cd4055..6e53af7 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java @@ -2,6 +2,7 @@ import com.oronaminc.join.global.dto.WriterDto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import lombok.Builder; @@ -10,7 +11,7 @@ @Schema(description = "질문 생성 응답 DTO") public record QuestionCreateResponse( @Schema(description = "", example = "CREATE") - String event, + EventType event, @Schema(description = "질문 ID", example = "11") Long questionId, @Schema(description = "질문 내용", example = "질문있습니다. 질문생성DTO가 맞나요?") diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java index a140a2a..a3f1044 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java @@ -1,11 +1,12 @@ package com.oronaminc.join.question.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Schema(description = "질문 삭제 응답 DTO") public record QuestionDeleteResponse( - String event, + EventType event, Long questionId ) { } diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java index efd0396..ee4388e 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.question.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Builder @Schema(description = "질문 수정 응답 DTO") public record QuestionUpdateResponse( - String event, + EventType event, Long questionId, String content diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionService.java b/src/main/java/com/oronaminc/join/question/service/QuestionService.java index c12597e..7a283a1 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionService.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionService.java @@ -45,9 +45,7 @@ public class QuestionService { public Question create(Long roomId, Long memberId, QuestionRequest requestDto) { Member member = memberReader.getById(memberId); - Room room = roomReader.getById(roomId); - participantService.validateParticipant(memberId, roomId); Question question = QuestionMapper.toQuestion(room, member, requestDto); @@ -86,12 +84,14 @@ public Question update(Long memberId, Long roomId, Long questionId, QuestionRequ // 참여자가 아님 if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { - throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + throw ErrorException.of(ErrorCode.NOT_FOUND_PARTICIPANT, + "{}번 발표방에는 {}번 회원이 잠가 중이지 않습니다.", roomId, memberId); } // 작성자가 아님 if (!question.getMember().getId().equals(memberId)) { - throw new ErrorException(ErrorCode.UNAUTHORIZED_EDIT_QUESTION); + throw ErrorException.of(ErrorCode.UNAUTHORIZED_EDIT_QUESTION, + "{}번 회원은 {}번 질문을 수정할 권한이 없습니다.", memberId, questionId); } question.updateContent(request.content()); @@ -105,13 +105,15 @@ public Long delete(Long memberId, Long roomId, Long questionId) { // 참여자가 아님 if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { - throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + throw ErrorException.of(ErrorCode.NOT_FOUND_PARTICIPANT, + "{}번 발표방에는 {}번 회원이 잠가 중이지 않습니다.", roomId, memberId); } // 관리자가 아님 && 작성자도 아님 if (!participantReader.existsPresenterOrTeamByMemberId(roomId, memberId) && !question.getMember().getId().equals(memberId)) { - throw new ErrorException(ErrorCode.UNAUTHORIZED_DELETE_QUESTION); + throw ErrorException.of(ErrorCode.UNAUTHORIZED_DELETE_QUESTION, + "{}번 회원은 {}번 질문을 삭제할 권한이 없습니다.", memberId, questionId); } answerService.deleteByQuestion(questionId); diff --git a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java index 9a784f0..4a045e8 100644 --- a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java +++ b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java @@ -12,6 +12,7 @@ import com.oronaminc.join.question.dto.QuestionListResponse; import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.springframework.data.domain.Slice; @@ -25,7 +26,7 @@ public static Question toQuestion(Room room, Member member, QuestionRequest requ public static QuestionCreateResponse toQuestionCreateResponse (Question question) { return QuestionCreateResponse.builder() - .event("CREATE") + .event(EventType.CREATE) .questionId(question.getId()) .content(question.getContent()) .emojiCount(0L) @@ -56,7 +57,7 @@ public static QuestionAssembleResponse toQuestionListResponse(QuestionFlatRespon public static QuestionUpdateResponse toQuestionUpdateResponse(Question question) { return QuestionUpdateResponse.builder() - .event("UPDATE") + .event(EventType.UPDATE) .questionId(question.getId()) .content(question.getContent()) .build(); @@ -64,7 +65,7 @@ public static QuestionUpdateResponse toQuestionUpdateResponse(Question question) public static QuestionDeleteResponse toQuestionDeleteResponse(Long questionId) { return new QuestionDeleteResponse( - "DELETE", + EventType.DELETE, questionId ); } diff --git a/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java b/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java index 39b86c4..558077a 100644 --- a/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java +++ b/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java @@ -1,21 +1,16 @@ package com.oronaminc.join.room.util; -import java.util.Random; - import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CodeGenerator { + private static final String CHAR_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private static final Random random = new Random(); public static String generateCode(int length) { - StringBuilder sb = new StringBuilder(length); - for (int i = 0; i < length; i++) { - int idx = random.nextInt(CHAR_POOL.length()); - sb.append(CHAR_POOL.charAt(idx)); - } - return sb.toString(); + + return RandomStringUtils.random(length, CHAR_POOL); } } diff --git a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java index 313e60f..12350e4 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java @@ -11,6 +11,7 @@ import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.websocket.common.EventType; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; import io.github.bucket4j.Bucket; @@ -84,7 +85,7 @@ public AnswerDeleteResponse delete( log.info("삭제되었습니다."); - return new AnswerDeleteResponse(answerId, "DELETE"); + return new AnswerDeleteResponse(answerId, EventType.DELETE); } private Long getMemberId(Principal principal) { diff --git a/src/main/java/com/oronaminc/join/websocket/common/EventType.java b/src/main/java/com/oronaminc/join/websocket/common/EventType.java new file mode 100644 index 0000000..b3bb363 --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/common/EventType.java @@ -0,0 +1,5 @@ +package com.oronaminc.join.websocket.common; + +public enum EventType { + CREATE, UPDATE, DELETE +} diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c98a2ce..c6b25c2 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,6 +2,7 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; +import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; @@ -30,7 +31,7 @@ public void handleSubscribe(SessionSubscribeEvent event) { Principal principal = accessor.getUser(); if (destination == null) { - throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + throw new ErrorException(STOMP_INVALID_DESTINATION); } if (!destination.startsWith(ROOM_PREFIX)) { @@ -66,7 +67,7 @@ private Long parseRoomId(String destination) { String[] parts = destination.split("/"); return Long.valueOf(parts[3]); } catch (Exception e) { - throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + throw new ErrorException(STOMP_INVALID_DESTINATION); } } diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java index bea6d9d..e369c8d 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java @@ -20,6 +20,7 @@ import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; +import com.oronaminc.join.websocket.common.EventType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -83,7 +84,7 @@ void toggleEmoji_createRoomEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -121,7 +122,7 @@ void toggleEmoji_createQuestionEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -159,7 +160,7 @@ void toggleEmoji_createAnswerEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -228,7 +229,7 @@ void toggleEmoji_deleteRoomEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); @@ -264,7 +265,7 @@ void toggleEmoji_deleteQuestionEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); @@ -300,7 +301,7 @@ void toggleEmoji_deleteAnswerEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); From a34d68066f0cf6118e38795f48cfe07f9e7a95f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Sun, 27 Jul 2025 12:44:37 +0900 Subject: [PATCH 28/74] =?UTF-8?q?fix:=20MemberController=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 --- .../join/member/api/MemberController.java | 4 ++-- .../join/member/security/AuthController.java | 17 +++++++---------- .../join/member/util/MemberMapper.java | 10 ++++++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/api/MemberController.java b/src/main/java/com/oronaminc/join/member/api/MemberController.java index d70b916..b48a3ce 100644 --- a/src/main/java/com/oronaminc/join/member/api/MemberController.java +++ b/src/main/java/com/oronaminc/join/member/api/MemberController.java @@ -46,8 +46,8 @@ public class MemberController { @GetMapping("/exists") @ResponseStatus(HttpStatus.OK) public ExistsMemberResponse existsMemberByEmail( - @Valid ExistsMemberRequest existsMemberRequest) { - boolean exists = memberService.existsMemberByEmail(existsMemberRequest.email()); + @RequestParam String email) { + boolean exists = memberService.existsMemberByEmail(email); return new ExistsMemberResponse(exists); } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 08e5d1f..4ac9476 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -1,5 +1,7 @@ package com.oronaminc.join.member.security; +import static com.oronaminc.join.member.util.MemberMapper.toSessionInfoResponse; + import java.util.List; import org.springframework.http.HttpStatus; @@ -46,7 +48,7 @@ public class AuthController { ) @PostMapping("/kakao") @ResponseStatus(HttpStatus.OK) - public KakaoLoginResponse kakaoLogin( + public SessionInfoResponse kakaoLogin( @RequestBody KakaoLoginRequest kakaoLoginRequest, HttpServletRequest request ) { @@ -64,7 +66,7 @@ public KakaoLoginResponse kakaoLogin( request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - return new KakaoLoginResponse(memberDetails.getId()); + return toSessionInfoResponse(memberDetails); } @Operation( @@ -77,7 +79,7 @@ public KakaoLoginResponse kakaoLogin( ) @PostMapping("/guest") @ResponseStatus(HttpStatus.CREATED) - public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guestLoginRequest, HttpServletRequest request) { + public SessionInfoResponse guestLogin(@RequestBody @Valid GuestLoginRequest guestLoginRequest, HttpServletRequest request) { MemberDetails guest = authService.loadGuest(guestLoginRequest); Authentication authentication = new UsernamePasswordAuthenticationToken( @@ -90,7 +92,7 @@ public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guest request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - return new GuestLoginResponse(guest.getId()); + return toSessionInfoResponse(guest); } @Operation( @@ -106,12 +108,7 @@ public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guest @ResponseStatus(HttpStatus.OK) public SessionInfoResponse getSessionInfo(@AuthenticationPrincipal MemberDetails memberDetails) { - return new SessionInfoResponse( - memberDetails.getId(), - memberDetails.getName(), - memberDetails.getNickname(), - memberDetails.getRole() - ); + return toSessionInfoResponse(memberDetails); } @Operation( diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java index 7835d9b..831d3c1 100644 --- a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -1,5 +1,6 @@ package com.oronaminc.join.member.util; +import com.oronaminc.join.member.dto.SessionInfoResponse; import java.util.Map; import com.oronaminc.join.member.domain.Member; @@ -65,4 +66,13 @@ public static KakaoUserResponse toKakaoUserResponse(Map kakaoAcc .profileImageUrl((String) profile.get("profile_image_url")) .build(); } + + public static SessionInfoResponse toSessionInfoResponse(MemberDetails memberDetails) { + return new SessionInfoResponse( + memberDetails.getId(), + memberDetails.getName(), + memberDetails.getNickname(), + memberDetails.getRole() + ); + } } From 08254232e6ac681ef7a2972521038b832971dfb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Sun, 27 Jul 2025 14:41:21 +0900 Subject: [PATCH 29/74] =?UTF-8?q?release:=20release=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=91=ED=95=A9=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> --- build.gradle | 2 + .../join/answer/dto/AnswerCreateResponse.java | 3 +- .../join/answer/dto/AnswerDeleteResponse.java | 3 +- .../join/answer/dto/AnswerUpdateResponse.java | 3 +- .../join/answer/mapper/AnswerMapper.java | 5 ++- .../join/emoji/dto/EmojiResponse.java | 3 +- .../join/emoji/service/EmojiService.java | 5 ++- .../join/global/exception/ErrorCode.java | 3 +- .../join/global/exception/ErrorException.java | 18 ++++++++- .../global/exception/ExceptionAdvice.java | 2 +- .../join/member/api/MemberController.java | 4 +- .../join/member/security/AuthController.java | 18 ++++----- .../join/member/util/MemberMapper.java | 10 +++++ .../dao/ParticipantRepository.java | 40 +++++++++---------- .../join/question/api/QuestionController.java | 3 +- .../question/dto/QuestionCreateResponse.java | 3 +- .../question/dto/QuestionDeleteResponse.java | 3 +- .../question/dto/QuestionUpdateResponse.java | 3 +- .../question/service/QuestionService.java | 14 ++++--- .../join/question/util/QuestionMapper.java | 7 ++-- .../join/room/util/CodeGenerator.java | 13 ++---- .../api/AnswerWebsocketController.java | 3 +- .../join/websocket/common/EventType.java | 5 +++ .../CurrentParticipantEventHandler.java | 5 ++- .../join/emoji/service/EmojiServiceTests.java | 13 +++--- 25 files changed, 116 insertions(+), 75 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/common/EventType.java diff --git a/build.gradle b/build.gradle index 828451c..3fb9bea 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,8 @@ dependencies { // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' + // commons-lang3 + implementation 'org.apache.commons:commons-lang3:3.18.0' // caffeine implementation 'com.github.ben-manes.caffeine:caffeine' diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java index 191ab18..c8e8c88 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java @@ -1,6 +1,7 @@ package com.oronaminc.join.answer.dto; import com.oronaminc.join.global.dto.WriterDto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -13,7 +14,7 @@ public record AnswerCreateResponse( @Schema(description = "답변이 생성될 질문 ID") Long questionId, @Schema(description = "답변 생성/삭제/수정 상태", example = "CREATE") - String event, + EventType event, @Schema(description = "답변 ID", example = "11") Long answerId, @Schema(description = "답변 내용", example = "답변입니다.") diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java index d8f4f64..36b5c9b 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.answer.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "답변 삭제 응답 DTO") public record AnswerDeleteResponse( Long answerId, @Schema(description = "삭제 이벤트", example = "DELETE") - String event + EventType event ) { } diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java index 3248b78..11f3835 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java @@ -1,5 +1,6 @@ package com.oronaminc.join.answer.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -8,7 +9,7 @@ public record AnswerUpdateResponse( Long answerId, @Schema(description = "수정 이벤트", example = "UPDATE") - String event, + EventType event, @Schema(description = "수정된 내용", example = "수정된 답변입니다.") String content diff --git a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java index 95dace3..0d27485 100644 --- a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java @@ -8,6 +8,7 @@ import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; +import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -17,7 +18,7 @@ public class AnswerMapper { public static AnswerCreateResponse toAnswerCreateResponse(Answer answer) { return AnswerCreateResponse.builder() .questionId(answer.getQuestion().getId()) - .event("CREATE") + .event(EventType.CREATE) .answerId(answer.getId()) .content(answer.getContent()) .emojiCount(0) @@ -51,7 +52,7 @@ public static Answer toEntity(Question question, Member member, AnswerRequest re public static AnswerUpdateResponse toAnswerUpdateResponse(Answer answer) { return AnswerUpdateResponse.builder() .answerId(answer.getId()) - .event("UPDATE") + .event(EventType.UPDATE) .content(answer.getContent()) .build(); } diff --git a/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java b/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java index eda6d68..99fc8a5 100644 --- a/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java +++ b/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.emoji.dto; import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "발표방/질문/답변 공감 생성/삭제 응답 DTO") public record EmojiResponse( @Schema(description = "이벤트 타입 (CREATE, DELETE)", example = "CREATE") - String event, + EventType event, @Schema(description = "공감 대상 타입 (ROOM, QUESTION, ANSWER)", example = "ROOM") TargetType targetType, @Schema(description = "공감 대상 ID", example = "1") diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java index 8bdc68b..4b20cae 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java @@ -11,6 +11,7 @@ import com.oronaminc.join.member.service.MemberReader; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.service.RoomReader; +import com.oronaminc.join.websocket.common.EventType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,7 +47,7 @@ public EmojiResponse createEmoji(Long memberId, EmojiRequest emojiRequest) { emojiCount = incrementEmojiCount(targetType, targetId); - return new EmojiResponse("CREATE", targetType, targetId, emojiCount); + return new EmojiResponse(EventType.CREATE, targetType, targetId, emojiCount); } @@ -62,7 +63,7 @@ public EmojiResponse deleteEmoji(Long memberId, EmojiRequest emojiRequest) { ); emojiCount = decrementEmojiCount(targetType, targetId); - return new EmojiResponse("DELETE", targetType, targetId, emojiCount); + return new EmojiResponse(EventType.DELETE, targetType, targetId, emojiCount); } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 5fc9cd7..865658b 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -31,7 +31,6 @@ public enum ErrorCode { UNAUTHORIZED_LIMIT_PARTICIPANT("PARTICIPANT-005", "인원이 가득 차 참가할 수 없습니다.", UNAUTHORIZED), UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-005", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), - FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), MOVEMENT_FILE_FAILED("FILE-003", "파일 이동이 실패하였습니다.", INTERNAL_SERVER_ERROR), @@ -51,7 +50,6 @@ public enum ErrorCode { UNAUTHORIZED_DELETE_ANSWER("ANSWER-005", "작성자 혹은 팀원, 발표자가 아니면 해당 댓글을 삭제할 수 없습니다.", UNAUTHORIZED), TOO_MANY_REQUESTS_ANSWER("ANSWER-006", "잠시 후 다시 시도해주세요.", UNAUTHORIZED), - ACCESS_DENIED_SESSION("SESSION-1201", "접근 권한이 없습니다.", FORBIDDEN), NOT_FOUND_SESSION("SESSION-1202", "세션이 유효하지 않습니다.", UNAUTHORIZED), EXPIRED_SESSION("SESSION-1203", "세션이 만료되었습니다.", UNAUTHORIZED), @@ -62,6 +60,7 @@ public enum ErrorCode { SOCKET_BAD_REQUEST_PATH("SOCKET-1002", "경로가 유효하지 않습니다.", BAD_REQUEST), SOCKET_BAD_REQUEST_MEMBER("SOCKET-1003", "회원이 유효하지 않습니다.", BAD_REQUEST), + STOMP_INVALID_DESTINATION("STOMP-001", "경로가 유효하지 않습니다.", BAD_REQUEST), CONFLICT_EMOJI("EMOJI-001", "공감 처리 중 충돌이 발생했습니다.", CONFLICT), NOT_FOUND_EMOJI("EMOJI-002", "해당 이모지가 존재하지 않습니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorException.java b/src/main/java/com/oronaminc/join/global/exception/ErrorException.java index 979f2c1..76828f7 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorException.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorException.java @@ -1,5 +1,7 @@ package com.oronaminc.join.global.exception; +import com.oronaminc.join.global.util.StringUtil; +import java.text.MessageFormat; import lombok.AllArgsConstructor; import lombok.Getter; @@ -8,5 +10,19 @@ public class ErrorException extends RuntimeException { private final ErrorCode errorCode; + private final String errorMessage; -} + public ErrorException(ErrorCode errorCode) { + this(errorCode, null); + } + + public static ErrorException of(ErrorCode errorCode, String message, Object... args) { + String errorMessage = createMessage(message, args); + return new ErrorException(errorCode, errorMessage); + } + + public static String createMessage(String message, Object... args) { + return StringUtil.format(message, args); + } + +} \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java index c166075..3572740 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java +++ b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java @@ -18,7 +18,7 @@ public class ExceptionAdvice { public ResponseEntity handleErrorException(ErrorException ex) { ErrorCode errorCode = ex.getErrorCode(); - log.error(errorCode.getMessage(), ex); + log.error("ErrorCode: {}, ErrorMessage: {}", ex.getErrorCode(), ex.getErrorMessage()); HttpStatus httpStatus = switch (errorCode.getErrorStatus()) { case NOT_FOUND -> HttpStatus.NOT_FOUND; diff --git a/src/main/java/com/oronaminc/join/member/api/MemberController.java b/src/main/java/com/oronaminc/join/member/api/MemberController.java index d70b916..b48a3ce 100644 --- a/src/main/java/com/oronaminc/join/member/api/MemberController.java +++ b/src/main/java/com/oronaminc/join/member/api/MemberController.java @@ -46,8 +46,8 @@ public class MemberController { @GetMapping("/exists") @ResponseStatus(HttpStatus.OK) public ExistsMemberResponse existsMemberByEmail( - @Valid ExistsMemberRequest existsMemberRequest) { - boolean exists = memberService.existsMemberByEmail(existsMemberRequest.email()); + @RequestParam String email) { + boolean exists = memberService.existsMemberByEmail(email); return new ExistsMemberResponse(exists); } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 08e5d1f..79e6eac 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -1,5 +1,8 @@ package com.oronaminc.join.member.security; + +import static com.oronaminc.join.member.util.MemberMapper.toSessionInfoResponse; + import java.util.List; import org.springframework.http.HttpStatus; @@ -46,7 +49,7 @@ public class AuthController { ) @PostMapping("/kakao") @ResponseStatus(HttpStatus.OK) - public KakaoLoginResponse kakaoLogin( + public SessionInfoResponse kakaoLogin( @RequestBody KakaoLoginRequest kakaoLoginRequest, HttpServletRequest request ) { @@ -64,7 +67,7 @@ public KakaoLoginResponse kakaoLogin( request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - return new KakaoLoginResponse(memberDetails.getId()); + return toSessionInfoResponse(memberDetails); } @Operation( @@ -77,7 +80,7 @@ public KakaoLoginResponse kakaoLogin( ) @PostMapping("/guest") @ResponseStatus(HttpStatus.CREATED) - public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guestLoginRequest, HttpServletRequest request) { + public SessionInfoResponse guestLogin(@RequestBody @Valid GuestLoginRequest guestLoginRequest, HttpServletRequest request) { MemberDetails guest = authService.loadGuest(guestLoginRequest); Authentication authentication = new UsernamePasswordAuthenticationToken( @@ -90,7 +93,7 @@ public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guest request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - return new GuestLoginResponse(guest.getId()); + return toSessionInfoResponse(guest); } @Operation( @@ -106,12 +109,7 @@ public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guest @ResponseStatus(HttpStatus.OK) public SessionInfoResponse getSessionInfo(@AuthenticationPrincipal MemberDetails memberDetails) { - return new SessionInfoResponse( - memberDetails.getId(), - memberDetails.getName(), - memberDetails.getNickname(), - memberDetails.getRole() - ); + return toSessionInfoResponse(memberDetails); } @Operation( diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java index 7835d9b..831d3c1 100644 --- a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -1,5 +1,6 @@ package com.oronaminc.join.member.util; +import com.oronaminc.join.member.dto.SessionInfoResponse; import java.util.Map; import com.oronaminc.join.member.domain.Member; @@ -65,4 +66,13 @@ public static KakaoUserResponse toKakaoUserResponse(Map kakaoAcc .profileImageUrl((String) profile.get("profile_image_url")) .build(); } + + public static SessionInfoResponse toSessionInfoResponse(MemberDetails memberDetails) { + return new SessionInfoResponse( + memberDetails.getId(), + memberDetails.getName(), + memberDetails.getNickname(), + memberDetails.getRole() + ); + } } diff --git a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java index 2b86ccc..6f200f8 100644 --- a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java +++ b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java @@ -15,11 +15,11 @@ public interface ParticipantRepository extends JpaRepository { - @Query( - "SELECT COUNT(p) > 0 " + - "FROM Participant p " + - "WHERE p.room.id = :roomId AND p.member.id = :memberId" - ) + @Query(""" + select COUNT(p) > 0 + from Participant p + where p.room.id = :roomId and p.member.id = :memberId + """) boolean existsByRoomIdAndMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); Optional findByRoomIdAndParticipantType(Long roomId, ParticipantType participantType); @@ -30,7 +30,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p where p.member.id = :memberId group by p.participantType - """) + """) List countByMemberIdGroupByParticipantType(Long memberId); @Query(""" @@ -38,7 +38,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p join fetch p.room where p.member.id = :memberId - """) + """) Page findByMemberId(Long memberId, Pageable pageable); @Query(""" @@ -46,7 +46,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p join fetch p.room where p.member.id = :memberId and p.participantType = :pType - """) + """) Page findByMemberIdAndParticipantType(Long memberId, ParticipantType pType, Pageable pageable); @@ -55,30 +55,30 @@ Page findByMemberIdAndParticipantType(Long memberId, ParticipantTyp from Participant p join fetch p.room where p.member.id = :memberId and p.participantType != :pType - """) + """) Page findByMemberIdAndParticipantTypeNot(Long memberId, ParticipantType pType, Pageable pageable); Optional findByRoomIdAndMemberId( @Param("roomId") Long roomId, @Param("memberId") Long memberId ); @Query(""" - SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END - FROM Participant p - WHERE p.room.id = :roomId - AND p.member.id = :memberId - AND (p.participantType = com.oronaminc.join.participant.domain.ParticipantType.PRESENTER - OR p.participantType = com.oronaminc.join.participant.domain.ParticipantType.TEAM) + select case when COUNT(p) > 0 then true else false end + from Participant p + where p.room.id = :roomId + and p.member.id = :memberId + and (p.participantType = com.oronaminc.join.participant.domain.ParticipantType.PRESENTER + or p.participantType = com.oronaminc.join.participant.domain.ParticipantType.TEAM) """) boolean existsPresenterOrTeamByMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); void deleteByRoomId(Long roomId); @Query(value = """ - SELECT COUNT(*) - FROM participant - WHERE room_id = :roomId - AND exited_at IS NOT NULL - AND TIMESTAMPDIFF(SECOND, created_at, exited_at) >= 30 + select COUNT(*) + from participant + where room_id = :roomId + and exited_at is not null + and TIMESTAMPDIFF(SECOND, created_at, exited_at) >= 30 """, nativeQuery = true) Long countParticipantsStayedOver30Seconds(@Param("roomId") Long roomId); } \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/question/api/QuestionController.java b/src/main/java/com/oronaminc/join/question/api/QuestionController.java index 2d183ee..901cfb7 100644 --- a/src/main/java/com/oronaminc/join/question/api/QuestionController.java +++ b/src/main/java/com/oronaminc/join/question/api/QuestionController.java @@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -30,7 +31,7 @@ public ResponseEntity getQuestions( @RequestParam(required = false) Long lastEmojiCount, @RequestParam(defaultValue = "10") int size, @RequestParam Long memberId, - @RequestParam Long roomId + @PathVariable Long roomId ) { Slice result = questionService.getQuestions( sort, lastId, lastEmojiCount, size, memberId, roomId diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java index 6cd4055..6e53af7 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java @@ -2,6 +2,7 @@ import com.oronaminc.join.global.dto.WriterDto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import lombok.Builder; @@ -10,7 +11,7 @@ @Schema(description = "질문 생성 응답 DTO") public record QuestionCreateResponse( @Schema(description = "", example = "CREATE") - String event, + EventType event, @Schema(description = "질문 ID", example = "11") Long questionId, @Schema(description = "질문 내용", example = "질문있습니다. 질문생성DTO가 맞나요?") diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java index a140a2a..a3f1044 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java @@ -1,11 +1,12 @@ package com.oronaminc.join.question.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Schema(description = "질문 삭제 응답 DTO") public record QuestionDeleteResponse( - String event, + EventType event, Long questionId ) { } diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java index efd0396..ee4388e 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.question.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Builder @Schema(description = "질문 수정 응답 DTO") public record QuestionUpdateResponse( - String event, + EventType event, Long questionId, String content diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionService.java b/src/main/java/com/oronaminc/join/question/service/QuestionService.java index c12597e..7a283a1 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionService.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionService.java @@ -45,9 +45,7 @@ public class QuestionService { public Question create(Long roomId, Long memberId, QuestionRequest requestDto) { Member member = memberReader.getById(memberId); - Room room = roomReader.getById(roomId); - participantService.validateParticipant(memberId, roomId); Question question = QuestionMapper.toQuestion(room, member, requestDto); @@ -86,12 +84,14 @@ public Question update(Long memberId, Long roomId, Long questionId, QuestionRequ // 참여자가 아님 if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { - throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + throw ErrorException.of(ErrorCode.NOT_FOUND_PARTICIPANT, + "{}번 발표방에는 {}번 회원이 잠가 중이지 않습니다.", roomId, memberId); } // 작성자가 아님 if (!question.getMember().getId().equals(memberId)) { - throw new ErrorException(ErrorCode.UNAUTHORIZED_EDIT_QUESTION); + throw ErrorException.of(ErrorCode.UNAUTHORIZED_EDIT_QUESTION, + "{}번 회원은 {}번 질문을 수정할 권한이 없습니다.", memberId, questionId); } question.updateContent(request.content()); @@ -105,13 +105,15 @@ public Long delete(Long memberId, Long roomId, Long questionId) { // 참여자가 아님 if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { - throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + throw ErrorException.of(ErrorCode.NOT_FOUND_PARTICIPANT, + "{}번 발표방에는 {}번 회원이 잠가 중이지 않습니다.", roomId, memberId); } // 관리자가 아님 && 작성자도 아님 if (!participantReader.existsPresenterOrTeamByMemberId(roomId, memberId) && !question.getMember().getId().equals(memberId)) { - throw new ErrorException(ErrorCode.UNAUTHORIZED_DELETE_QUESTION); + throw ErrorException.of(ErrorCode.UNAUTHORIZED_DELETE_QUESTION, + "{}번 회원은 {}번 질문을 삭제할 권한이 없습니다.", memberId, questionId); } answerService.deleteByQuestion(questionId); diff --git a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java index 9a784f0..4a045e8 100644 --- a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java +++ b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java @@ -12,6 +12,7 @@ import com.oronaminc.join.question.dto.QuestionListResponse; import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.springframework.data.domain.Slice; @@ -25,7 +26,7 @@ public static Question toQuestion(Room room, Member member, QuestionRequest requ public static QuestionCreateResponse toQuestionCreateResponse (Question question) { return QuestionCreateResponse.builder() - .event("CREATE") + .event(EventType.CREATE) .questionId(question.getId()) .content(question.getContent()) .emojiCount(0L) @@ -56,7 +57,7 @@ public static QuestionAssembleResponse toQuestionListResponse(QuestionFlatRespon public static QuestionUpdateResponse toQuestionUpdateResponse(Question question) { return QuestionUpdateResponse.builder() - .event("UPDATE") + .event(EventType.UPDATE) .questionId(question.getId()) .content(question.getContent()) .build(); @@ -64,7 +65,7 @@ public static QuestionUpdateResponse toQuestionUpdateResponse(Question question) public static QuestionDeleteResponse toQuestionDeleteResponse(Long questionId) { return new QuestionDeleteResponse( - "DELETE", + EventType.DELETE, questionId ); } diff --git a/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java b/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java index 39b86c4..558077a 100644 --- a/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java +++ b/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java @@ -1,21 +1,16 @@ package com.oronaminc.join.room.util; -import java.util.Random; - import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CodeGenerator { + private static final String CHAR_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private static final Random random = new Random(); public static String generateCode(int length) { - StringBuilder sb = new StringBuilder(length); - for (int i = 0; i < length; i++) { - int idx = random.nextInt(CHAR_POOL.length()); - sb.append(CHAR_POOL.charAt(idx)); - } - return sb.toString(); + + return RandomStringUtils.random(length, CHAR_POOL); } } diff --git a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java index 313e60f..12350e4 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java @@ -11,6 +11,7 @@ import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.websocket.common.EventType; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; import io.github.bucket4j.Bucket; @@ -84,7 +85,7 @@ public AnswerDeleteResponse delete( log.info("삭제되었습니다."); - return new AnswerDeleteResponse(answerId, "DELETE"); + return new AnswerDeleteResponse(answerId, EventType.DELETE); } private Long getMemberId(Principal principal) { diff --git a/src/main/java/com/oronaminc/join/websocket/common/EventType.java b/src/main/java/com/oronaminc/join/websocket/common/EventType.java new file mode 100644 index 0000000..b3bb363 --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/common/EventType.java @@ -0,0 +1,5 @@ +package com.oronaminc.join.websocket.common; + +public enum EventType { + CREATE, UPDATE, DELETE +} diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c98a2ce..c6b25c2 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,6 +2,7 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; +import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; @@ -30,7 +31,7 @@ public void handleSubscribe(SessionSubscribeEvent event) { Principal principal = accessor.getUser(); if (destination == null) { - throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + throw new ErrorException(STOMP_INVALID_DESTINATION); } if (!destination.startsWith(ROOM_PREFIX)) { @@ -66,7 +67,7 @@ private Long parseRoomId(String destination) { String[] parts = destination.split("/"); return Long.valueOf(parts[3]); } catch (Exception e) { - throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + throw new ErrorException(STOMP_INVALID_DESTINATION); } } diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java index bea6d9d..e369c8d 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java @@ -20,6 +20,7 @@ import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; +import com.oronaminc.join.websocket.common.EventType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -83,7 +84,7 @@ void toggleEmoji_createRoomEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -121,7 +122,7 @@ void toggleEmoji_createQuestionEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -159,7 +160,7 @@ void toggleEmoji_createAnswerEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -228,7 +229,7 @@ void toggleEmoji_deleteRoomEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); @@ -264,7 +265,7 @@ void toggleEmoji_deleteQuestionEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); @@ -300,7 +301,7 @@ void toggleEmoji_deleteAnswerEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); From 492992a26cc84005e6619b26a77e0862fea9a8d2 Mon Sep 17 00:00:00 2001 From: SeungTae <122506273+gffd94@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:47:01 +0900 Subject: [PATCH 30/74] =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/answer/api/AnswerController.java | 18 ++- .../join/answer/dao/AnswerRepository.java | 49 +++++-- .../oronaminc/join/answer/domain/Answer.java | 1 - .../join/answer/dto/AnswerGetResponse.java | 2 +- .../join/answer/dto/AnswerListResponse.java | 11 ++ .../join/answer/mapper/AnswerMapper.java | 18 ++- .../join/answer/service/AnswerReader.java | 31 +++-- .../join/answer/service/AnswerService.java | 48 +++++-- .../join/emoji/dao/EmojiRepository.java | 17 +++ .../join/emoji/service/EmojiReader.java | 7 + .../join/question/domain/Question.java | 1 - .../answer/service/AnswerServiceTests.java | 129 +++++++++++++----- 12 files changed, 252 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java diff --git a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java index cc6c30c..7452a6a 100644 --- a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java +++ b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java @@ -1,17 +1,22 @@ package com.oronaminc.join.answer.api; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerListResponse; +import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.member.security.MemberDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -33,14 +38,19 @@ public class AnswerController { ) @GetMapping("/rooms/{roomId}/questions/{questionId}/answers") @ResponseStatus(HttpStatus.OK) - public ResponseEntity getAnswer( + public ResponseEntity getAnswers( @PathVariable Long roomId, @PathVariable Long questionId, - @AuthenticationPrincipal MemberDetails memberDetails + @AuthenticationPrincipal MemberDetails memberDetails, + @RequestParam(required = false) Long lastId, + @RequestParam(required = false) LocalDateTime lastCreatedAt, + @RequestParam(defaultValue = "10") int size ) { Long memberId = memberDetails.getId(); - AnswerGetResponse response = answerService.getAnswer(roomId, questionId, memberId); - return ResponseEntity.ok(response); + + Slice response = answerService.getAnswers(roomId, questionId, memberId, + lastId, lastCreatedAt, size); + return ResponseEntity.ok(AnswerMapper.toAnswerListResponse(response)); } } diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index 265fb45..d06e03a 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -1,10 +1,12 @@ package com.oronaminc.join.answer.dao; +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.question.domain.Question; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.question.domain.Question; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -12,24 +14,49 @@ public interface AnswerRepository extends JpaRepository { Optional findByQuestionId(Long questionId); - boolean existsByQuestionIdAndMemberId(Long questionId, Long memberId); + @Query(""" + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + ORDER BY a.createdAt DESC, a.id DESC + """) + List findFirstPageByQuestionId( + @Param("questionId") Long questionId, + Pageable pageable + ); + + @Query(""" + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + AND (a.createdAt < :lastCreatedAt OR (a.createdAt = :lastCreatedAt AND a.id < :lastId)) + ORDER BY a.createdAt DESC, a.id DESC + """) + List findByQuestionIdWithCursor( + @Param("questionId") Long questionId, + @Param("lastCreatedAt") LocalDateTime lastCreatedAt, + @Param("lastId") Long lastId, + Pageable pageable + ); void deleteByQuestionId(Long questionId); void deleteByQuestionIn(List questions); @Query(""" - select count(distinct a.question.id) - from Answer a - where a.question.room.id = :roomId - """) + select count(distinct a.question.id) + from Answer a + where a.question.room.id = :roomId + """) Long countAnsweredQuestionsByRoomId(@Param("roomId") Long roomId); @Query(""" - select a - from Answer a - where a.question.id in :questionIds - """) + select a + from Answer a + where a.question.id in :questionIds + """) List findAllByQuestionIds(@Param("questionIds") List questionIds); } diff --git a/src/main/java/com/oronaminc/join/answer/domain/Answer.java b/src/main/java/com/oronaminc/join/answer/domain/Answer.java index fe782ee..7abdf84 100644 --- a/src/main/java/com/oronaminc/join/answer/domain/Answer.java +++ b/src/main/java/com/oronaminc/join/answer/domain/Answer.java @@ -26,7 +26,6 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -// TODO: ddl-auto: create,update에만 유효 -> 추후 flyway sql 생성 @Table(name = "answer", indexes = { @Index(name = "idx_answer_question_member", columnList = "question_id, member_id") }) diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java index 5970acc..9de2804 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java @@ -12,7 +12,7 @@ public record AnswerGetResponse( @Schema(description = "답변 내용에 대한 공감 수", example = "23") Long emojiCount, @Schema(description = "답변 공감 여부", example = "true") - boolean Emojied, + boolean isEmojied, @Schema(description = "답변 내용", example = "답변입니다.") String content, @Schema(description = "작성자 정보 DTO") diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java new file mode 100644 index 0000000..56870b3 --- /dev/null +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.answer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "답변 목록을 묶기 위한 DTO") +public record AnswerListResponse( + List answers +) { + +} diff --git a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java index 0d27485..2e7d62d 100644 --- a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java @@ -1,16 +1,20 @@ package com.oronaminc.join.answer.mapper; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerCreateResponse; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerListResponse; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerUpdateResponse; +import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.emoji.service.EmojiReader; import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.springframework.data.domain.Slice; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class AnswerMapper { @@ -31,11 +35,12 @@ public static AnswerCreateResponse toAnswerCreateResponse(Answer answer) { .build(); } - public static AnswerGetResponse toAnswerGetResponse(Answer answer, Long emojiCount, boolean isEmojied) { + public static AnswerGetResponse toAnswerGetResponse(Answer answer, boolean isEmojied) { + return AnswerGetResponse.builder() .answerId(answer.getId()) - .emojiCount(emojiCount) - .Emojied(isEmojied) + .emojiCount(answer.getEmojiCount()) + .isEmojied(isEmojied) .content(answer.getContent()) .writer(new WriterDto( answer.getMember().getId(), @@ -57,4 +62,9 @@ public static AnswerUpdateResponse toAnswerUpdateResponse(Answer answer) { .build(); } + public static AnswerListResponse toAnswerListResponse( + Slice slice) { + return new AnswerListResponse(slice.getContent()); + } + } diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java index 1935242..773a534 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java @@ -1,33 +1,36 @@ package com.oronaminc.join.answer.service; -import static com.oronaminc.join.global.exception.ErrorCode.*; - -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.room.domain.Room; -import java.util.List; -import java.util.Optional; - -import org.springframework.stereotype.Component; - import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; - +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class AnswerReader { - private final AnswerRepository answerRepository; - public boolean existsByQuestionIdAndMemberId(Long questionId, Long memberId) { - return answerRepository.existsByQuestionIdAndMemberId(questionId, memberId); - } + private final AnswerRepository answerRepository; public Optional findById(Long answerId) { return answerRepository.findById(answerId); } + public List getFirstPageByQuestionId(Long questionId, Pageable pageable) { + return answerRepository.findFirstPageByQuestionId(questionId, pageable); + } + + public List getAnswerByQuestionIdWithCursor(Long questionId, + LocalDateTime lastCreatedAt, Long lastId, Pageable pageable) { + return answerRepository.findByQuestionIdWithCursor(questionId, lastCreatedAt, lastId, + pageable); + } + public Answer getByQuestionId(Long questionId) { return answerRepository.findByQuestionId(questionId) .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_EXIST_ANSWER)); diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index 6436723..cc06dff 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -9,15 +9,20 @@ import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.service.EmojiReader; +import com.oronaminc.join.global.util.SliceUtil; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; +import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,18 +53,43 @@ public Answer create(Long roomId, Long memberId, Long questionId, } - @Transactional - public AnswerGetResponse getAnswer(Long roomId, Long questionId, Long memberId) { - Member member = memberReader.getById(memberId); + @Transactional(readOnly = true) + public Slice getAnswers( + Long roomId, + Long questionId, + Long memberId, + Long lastId, + LocalDateTime lastCreatedAt, + int size + ) { + memberReader.getById(memberId); roomReader.getById(roomId); questionReader.getByIdAndRoomId(questionId, roomId); - Answer answer = answerReader.getByQuestionId(questionId); + answerReader.getByQuestionId(questionId); + + Pageable pageable = PageRequest.of(0, size + 1); + + List answers = (lastCreatedAt == null || lastId == null) + ? answerReader.getFirstPageByQuestionId(questionId, pageable) + : answerReader.getAnswerByQuestionIdWithCursor(questionId, lastCreatedAt, lastId, + pageable); + + // 공감 여부 일괄 조회 + List answerIds = answers.stream().map(Answer::getId).toList(); + + Set emojiedAnswerIds = memberId != null + ? emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(memberId, TargetType.ANSWER, answerIds) + : Set.of(); + + List responseList = answers.stream() + .map(answer -> { + boolean isEmojied = emojiedAnswerIds.contains(answer.getId()); + return AnswerMapper.toAnswerGetResponse(answer, isEmojied); + }) + .toList(); - Long emojiCount = answer.getEmojiCount(); - boolean isEmojied = emojiReader.findByMemberIdAndTargetIdAndTargetType(member.getId(), - answer.getId(), TargetType.ANSWER).isPresent(); + return SliceUtil.toSlice(responseList, PageRequest.of(0, size)); - return AnswerMapper.toAnswerGetResponse(answer, emojiCount, isEmojied); } @Transactional diff --git a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java index a4c62e9..3389a9b 100644 --- a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java +++ b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java @@ -2,8 +2,12 @@ import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface EmojiRepository extends JpaRepository { @@ -16,4 +20,17 @@ Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targe boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, TargetType targetType); + + @Query(""" + SELECT e.targetId + FROM Emoji e + WHERE e.member.id = :memberId + AND e.targetType = :targetType + AND e.targetId IN :targetIds + """) + Set findTargetIdsByMemberIdAndTargetTypeAndTargetIdIn( + @Param("memberId") Long memberId, + @Param("targetType") TargetType targetType, + @Param("targetIds") List targetIds + ); } diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java index 99a3552..6bf8182 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java @@ -5,7 +5,9 @@ import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; +import java.util.List; import java.util.Optional; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -38,4 +40,9 @@ public boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targ targetType); } + public Set findTargetIdsByMemberAndTargetTypeInBatch(Long memberId, TargetType targetType, List targetIds) { + if (targetIds.isEmpty() || targetType == null) return Set.of(); + return emojiRepository.findTargetIdsByMemberIdAndTargetTypeAndTargetIdIn(memberId, targetType, targetIds); + } + } diff --git a/src/main/java/com/oronaminc/join/question/domain/Question.java b/src/main/java/com/oronaminc/join/question/domain/Question.java index d3d7d26..d3e9caa 100644 --- a/src/main/java/com/oronaminc/join/question/domain/Question.java +++ b/src/main/java/com/oronaminc/join/question/domain/Question.java @@ -25,7 +25,6 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -// TODO: ddl-auto: create,update에만 유효 -> 추후 flyway sql 생성 @Table(name = "question", indexes = { @Index(name = "idx_question_id_room", columnList = "room_id") }) diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index b5f14af..9571356 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -3,10 +3,11 @@ import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_ROOM; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.LIST; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; @@ -31,7 +32,8 @@ import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; import java.time.LocalDateTime; -import java.util.Optional; +import java.util.List; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,6 +41,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Slice; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class AnswerServiceTests { @@ -142,47 +146,102 @@ void createAnswer_success() { } @Test - @DisplayName("답변 조회 성공") - void getAnswer_success() { + @DisplayName("답변 목록 조회 - 커서 없이 최초 페이지 조회") + void getAnswers_firstPage_success() { // given - Long memberId = 1L; - Long roomId = 1L; - Long questionId = 1L; + List answers = List.of(createAnswer(100L,LocalDateTime.now()), createAnswer(99L,LocalDateTime.now() )); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(100L, 99L))) + .willReturn(Set.of(100L)); - Answer mockAnswer = Answer.builder() - .id(10L) - .question(mockQuestion) - .member(mockMember) - .content("답변입니다.") - .emojiCount(5L) - .version(0) - .build(); + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); - mockEmoji = Emoji.builder() - .id(1L) - .member(mockMember) - .targetType(TargetType.ANSWER) - .targetId(mockAnswer.getId()) - .build(); + // then + assertThat(response.getContent().get(0).answerId()).isEqualTo(100L); + assertThat(response.getContent().get(0).isEmojied()).isTrue(); + } - // mocking - given(memberReader.getById(memberId)).willReturn(mockMember); - given(roomReader.getById(roomId)).willReturn(mockRoom); - given(questionReader.getByIdAndRoomId(questionId, roomId)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(questionId)).willReturn(mockAnswer); - given(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, mockAnswer.getId(), - TargetType.ANSWER)).willReturn(Optional.of(mockEmoji)); + @Test + @DisplayName("답변 목록 조회 - 커서 기준 이후 답변 조회") + void getAnswers_cursorPaging_success() { + // given + List answers = List.of(createAnswer(80L,LocalDateTime.now()), createAnswer(79L,LocalDateTime.now())); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getAnswerByQuestionIdWithCursor(eq(1L), any(), any(), any())).willReturn( + answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(80L, 79L))) + .willReturn(Set.of()); // when - AnswerGetResponse response = answerService.getAnswer(roomId, questionId, memberId); + Slice response = answerService.getAnswers(1L, 1L, 1L, 90L, + LocalDateTime.now(), 10); // then - assertThat(response.answerId()).isEqualTo(mockAnswer.getId()); - assertThat(response.content()).isEqualTo(mockAnswer.getContent()); - assertThat(response.emojiCount()).isEqualTo(5L); - assertThat(response.Emojied()).isTrue(); - assertThat(response.writer().memberId()).isEqualTo(mockMember.getId()); - assertThat(response.writer().nickname()).isEqualTo(mockMember.getNickname()); + assertThat(response.getContent().get(0).answerId()).isEqualTo(80L); + assertThat(response.getContent().get(0).isEmojied()).isFalse(); + } + + @Test + @DisplayName("답변 목록 조회 - 공감이 포함된 답변들 조회") + void getAnswers_containsEmojiedAnswers() { + // given + List answers = List.of(createAnswer(1L,LocalDateTime.now()), createAnswer(2L,LocalDateTime.now())); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(1L, 2L))) + .willReturn(Set.of(2L)); + + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); + + // then + assertThat(response.getContent().get(0).isEmojied()).isFalse(); + assertThat(response.getContent().get(1).isEmojied()).isTrue(); + } + + + @Test + @DisplayName("답변 목록 조회 - 결과가 비어도 예외 없이 처리") + void getAnswers_emptyList_noError() { + // given + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(List.of()); + + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); + + // then + assertThat(response.getContent()).asInstanceOf(LIST).isEmpty(); + } + + private Answer createAnswer(Long id, LocalDateTime createdAt) { + Answer answer = Answer.builder() + .id(id) + .member(mockMember) + .question(mockQuestion) + .content("답변입니다") + .emojiCount(0L) + .build(); + + ReflectionTestUtils.setField(answer, "createdAt", createdAt); + return answer; } @Test From b203f2d3007158591aaae002b62d1afb1fe7e43e Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:40:26 +0900 Subject: [PATCH 31/74] =?UTF-8?q?release:=20=EA=B0=9C=EB=B0=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=BD=94=EB=93=9C=20release=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=91=ED=95=A9=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) --------- Co-authored-by: 김건우 <96411818+rjswjddn@users.noreply.github.com> Co-authored-by: 김건우 Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/answer/api/AnswerController.java | 18 ++- .../join/answer/dao/AnswerRepository.java | 49 +++++-- .../oronaminc/join/answer/domain/Answer.java | 1 - .../join/answer/dto/AnswerGetResponse.java | 2 +- .../join/answer/dto/AnswerListResponse.java | 11 ++ .../join/answer/mapper/AnswerMapper.java | 18 ++- .../join/answer/service/AnswerReader.java | 31 +++-- .../join/answer/service/AnswerService.java | 48 +++++-- .../join/emoji/dao/EmojiRepository.java | 17 +++ .../join/emoji/service/EmojiReader.java | 7 + .../join/question/domain/Question.java | 1 - .../answer/service/AnswerServiceTests.java | 129 +++++++++++++----- 12 files changed, 252 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java diff --git a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java index cc6c30c..7452a6a 100644 --- a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java +++ b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java @@ -1,17 +1,22 @@ package com.oronaminc.join.answer.api; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerListResponse; +import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.member.security.MemberDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -33,14 +38,19 @@ public class AnswerController { ) @GetMapping("/rooms/{roomId}/questions/{questionId}/answers") @ResponseStatus(HttpStatus.OK) - public ResponseEntity getAnswer( + public ResponseEntity getAnswers( @PathVariable Long roomId, @PathVariable Long questionId, - @AuthenticationPrincipal MemberDetails memberDetails + @AuthenticationPrincipal MemberDetails memberDetails, + @RequestParam(required = false) Long lastId, + @RequestParam(required = false) LocalDateTime lastCreatedAt, + @RequestParam(defaultValue = "10") int size ) { Long memberId = memberDetails.getId(); - AnswerGetResponse response = answerService.getAnswer(roomId, questionId, memberId); - return ResponseEntity.ok(response); + + Slice response = answerService.getAnswers(roomId, questionId, memberId, + lastId, lastCreatedAt, size); + return ResponseEntity.ok(AnswerMapper.toAnswerListResponse(response)); } } diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index 265fb45..d06e03a 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -1,10 +1,12 @@ package com.oronaminc.join.answer.dao; +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.question.domain.Question; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.question.domain.Question; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -12,24 +14,49 @@ public interface AnswerRepository extends JpaRepository { Optional findByQuestionId(Long questionId); - boolean existsByQuestionIdAndMemberId(Long questionId, Long memberId); + @Query(""" + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + ORDER BY a.createdAt DESC, a.id DESC + """) + List findFirstPageByQuestionId( + @Param("questionId") Long questionId, + Pageable pageable + ); + + @Query(""" + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + AND (a.createdAt < :lastCreatedAt OR (a.createdAt = :lastCreatedAt AND a.id < :lastId)) + ORDER BY a.createdAt DESC, a.id DESC + """) + List findByQuestionIdWithCursor( + @Param("questionId") Long questionId, + @Param("lastCreatedAt") LocalDateTime lastCreatedAt, + @Param("lastId") Long lastId, + Pageable pageable + ); void deleteByQuestionId(Long questionId); void deleteByQuestionIn(List questions); @Query(""" - select count(distinct a.question.id) - from Answer a - where a.question.room.id = :roomId - """) + select count(distinct a.question.id) + from Answer a + where a.question.room.id = :roomId + """) Long countAnsweredQuestionsByRoomId(@Param("roomId") Long roomId); @Query(""" - select a - from Answer a - where a.question.id in :questionIds - """) + select a + from Answer a + where a.question.id in :questionIds + """) List findAllByQuestionIds(@Param("questionIds") List questionIds); } diff --git a/src/main/java/com/oronaminc/join/answer/domain/Answer.java b/src/main/java/com/oronaminc/join/answer/domain/Answer.java index fe782ee..7abdf84 100644 --- a/src/main/java/com/oronaminc/join/answer/domain/Answer.java +++ b/src/main/java/com/oronaminc/join/answer/domain/Answer.java @@ -26,7 +26,6 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -// TODO: ddl-auto: create,update에만 유효 -> 추후 flyway sql 생성 @Table(name = "answer", indexes = { @Index(name = "idx_answer_question_member", columnList = "question_id, member_id") }) diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java index 5970acc..9de2804 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java @@ -12,7 +12,7 @@ public record AnswerGetResponse( @Schema(description = "답변 내용에 대한 공감 수", example = "23") Long emojiCount, @Schema(description = "답변 공감 여부", example = "true") - boolean Emojied, + boolean isEmojied, @Schema(description = "답변 내용", example = "답변입니다.") String content, @Schema(description = "작성자 정보 DTO") diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java new file mode 100644 index 0000000..56870b3 --- /dev/null +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.answer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "답변 목록을 묶기 위한 DTO") +public record AnswerListResponse( + List answers +) { + +} diff --git a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java index 0d27485..2e7d62d 100644 --- a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java @@ -1,16 +1,20 @@ package com.oronaminc.join.answer.mapper; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerCreateResponse; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerListResponse; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerUpdateResponse; +import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.emoji.service.EmojiReader; import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.springframework.data.domain.Slice; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class AnswerMapper { @@ -31,11 +35,12 @@ public static AnswerCreateResponse toAnswerCreateResponse(Answer answer) { .build(); } - public static AnswerGetResponse toAnswerGetResponse(Answer answer, Long emojiCount, boolean isEmojied) { + public static AnswerGetResponse toAnswerGetResponse(Answer answer, boolean isEmojied) { + return AnswerGetResponse.builder() .answerId(answer.getId()) - .emojiCount(emojiCount) - .Emojied(isEmojied) + .emojiCount(answer.getEmojiCount()) + .isEmojied(isEmojied) .content(answer.getContent()) .writer(new WriterDto( answer.getMember().getId(), @@ -57,4 +62,9 @@ public static AnswerUpdateResponse toAnswerUpdateResponse(Answer answer) { .build(); } + public static AnswerListResponse toAnswerListResponse( + Slice slice) { + return new AnswerListResponse(slice.getContent()); + } + } diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java index 1935242..773a534 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java @@ -1,33 +1,36 @@ package com.oronaminc.join.answer.service; -import static com.oronaminc.join.global.exception.ErrorCode.*; - -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.room.domain.Room; -import java.util.List; -import java.util.Optional; - -import org.springframework.stereotype.Component; - import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; - +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class AnswerReader { - private final AnswerRepository answerRepository; - public boolean existsByQuestionIdAndMemberId(Long questionId, Long memberId) { - return answerRepository.existsByQuestionIdAndMemberId(questionId, memberId); - } + private final AnswerRepository answerRepository; public Optional findById(Long answerId) { return answerRepository.findById(answerId); } + public List getFirstPageByQuestionId(Long questionId, Pageable pageable) { + return answerRepository.findFirstPageByQuestionId(questionId, pageable); + } + + public List getAnswerByQuestionIdWithCursor(Long questionId, + LocalDateTime lastCreatedAt, Long lastId, Pageable pageable) { + return answerRepository.findByQuestionIdWithCursor(questionId, lastCreatedAt, lastId, + pageable); + } + public Answer getByQuestionId(Long questionId) { return answerRepository.findByQuestionId(questionId) .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_EXIST_ANSWER)); diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index 6436723..cc06dff 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -9,15 +9,20 @@ import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.service.EmojiReader; +import com.oronaminc.join.global.util.SliceUtil; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; +import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,18 +53,43 @@ public Answer create(Long roomId, Long memberId, Long questionId, } - @Transactional - public AnswerGetResponse getAnswer(Long roomId, Long questionId, Long memberId) { - Member member = memberReader.getById(memberId); + @Transactional(readOnly = true) + public Slice getAnswers( + Long roomId, + Long questionId, + Long memberId, + Long lastId, + LocalDateTime lastCreatedAt, + int size + ) { + memberReader.getById(memberId); roomReader.getById(roomId); questionReader.getByIdAndRoomId(questionId, roomId); - Answer answer = answerReader.getByQuestionId(questionId); + answerReader.getByQuestionId(questionId); + + Pageable pageable = PageRequest.of(0, size + 1); + + List answers = (lastCreatedAt == null || lastId == null) + ? answerReader.getFirstPageByQuestionId(questionId, pageable) + : answerReader.getAnswerByQuestionIdWithCursor(questionId, lastCreatedAt, lastId, + pageable); + + // 공감 여부 일괄 조회 + List answerIds = answers.stream().map(Answer::getId).toList(); + + Set emojiedAnswerIds = memberId != null + ? emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(memberId, TargetType.ANSWER, answerIds) + : Set.of(); + + List responseList = answers.stream() + .map(answer -> { + boolean isEmojied = emojiedAnswerIds.contains(answer.getId()); + return AnswerMapper.toAnswerGetResponse(answer, isEmojied); + }) + .toList(); - Long emojiCount = answer.getEmojiCount(); - boolean isEmojied = emojiReader.findByMemberIdAndTargetIdAndTargetType(member.getId(), - answer.getId(), TargetType.ANSWER).isPresent(); + return SliceUtil.toSlice(responseList, PageRequest.of(0, size)); - return AnswerMapper.toAnswerGetResponse(answer, emojiCount, isEmojied); } @Transactional diff --git a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java index a4c62e9..3389a9b 100644 --- a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java +++ b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java @@ -2,8 +2,12 @@ import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface EmojiRepository extends JpaRepository { @@ -16,4 +20,17 @@ Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targe boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, TargetType targetType); + + @Query(""" + SELECT e.targetId + FROM Emoji e + WHERE e.member.id = :memberId + AND e.targetType = :targetType + AND e.targetId IN :targetIds + """) + Set findTargetIdsByMemberIdAndTargetTypeAndTargetIdIn( + @Param("memberId") Long memberId, + @Param("targetType") TargetType targetType, + @Param("targetIds") List targetIds + ); } diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java index 99a3552..6bf8182 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java @@ -5,7 +5,9 @@ import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; +import java.util.List; import java.util.Optional; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -38,4 +40,9 @@ public boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targ targetType); } + public Set findTargetIdsByMemberAndTargetTypeInBatch(Long memberId, TargetType targetType, List targetIds) { + if (targetIds.isEmpty() || targetType == null) return Set.of(); + return emojiRepository.findTargetIdsByMemberIdAndTargetTypeAndTargetIdIn(memberId, targetType, targetIds); + } + } diff --git a/src/main/java/com/oronaminc/join/question/domain/Question.java b/src/main/java/com/oronaminc/join/question/domain/Question.java index d3d7d26..d3e9caa 100644 --- a/src/main/java/com/oronaminc/join/question/domain/Question.java +++ b/src/main/java/com/oronaminc/join/question/domain/Question.java @@ -25,7 +25,6 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -// TODO: ddl-auto: create,update에만 유효 -> 추후 flyway sql 생성 @Table(name = "question", indexes = { @Index(name = "idx_question_id_room", columnList = "room_id") }) diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index b5f14af..9571356 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -3,10 +3,11 @@ import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_ROOM; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.LIST; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; @@ -31,7 +32,8 @@ import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; import java.time.LocalDateTime; -import java.util.Optional; +import java.util.List; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,6 +41,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Slice; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class AnswerServiceTests { @@ -142,47 +146,102 @@ void createAnswer_success() { } @Test - @DisplayName("답변 조회 성공") - void getAnswer_success() { + @DisplayName("답변 목록 조회 - 커서 없이 최초 페이지 조회") + void getAnswers_firstPage_success() { // given - Long memberId = 1L; - Long roomId = 1L; - Long questionId = 1L; + List answers = List.of(createAnswer(100L,LocalDateTime.now()), createAnswer(99L,LocalDateTime.now() )); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(100L, 99L))) + .willReturn(Set.of(100L)); - Answer mockAnswer = Answer.builder() - .id(10L) - .question(mockQuestion) - .member(mockMember) - .content("답변입니다.") - .emojiCount(5L) - .version(0) - .build(); + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); - mockEmoji = Emoji.builder() - .id(1L) - .member(mockMember) - .targetType(TargetType.ANSWER) - .targetId(mockAnswer.getId()) - .build(); + // then + assertThat(response.getContent().get(0).answerId()).isEqualTo(100L); + assertThat(response.getContent().get(0).isEmojied()).isTrue(); + } - // mocking - given(memberReader.getById(memberId)).willReturn(mockMember); - given(roomReader.getById(roomId)).willReturn(mockRoom); - given(questionReader.getByIdAndRoomId(questionId, roomId)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(questionId)).willReturn(mockAnswer); - given(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, mockAnswer.getId(), - TargetType.ANSWER)).willReturn(Optional.of(mockEmoji)); + @Test + @DisplayName("답변 목록 조회 - 커서 기준 이후 답변 조회") + void getAnswers_cursorPaging_success() { + // given + List answers = List.of(createAnswer(80L,LocalDateTime.now()), createAnswer(79L,LocalDateTime.now())); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getAnswerByQuestionIdWithCursor(eq(1L), any(), any(), any())).willReturn( + answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(80L, 79L))) + .willReturn(Set.of()); // when - AnswerGetResponse response = answerService.getAnswer(roomId, questionId, memberId); + Slice response = answerService.getAnswers(1L, 1L, 1L, 90L, + LocalDateTime.now(), 10); // then - assertThat(response.answerId()).isEqualTo(mockAnswer.getId()); - assertThat(response.content()).isEqualTo(mockAnswer.getContent()); - assertThat(response.emojiCount()).isEqualTo(5L); - assertThat(response.Emojied()).isTrue(); - assertThat(response.writer().memberId()).isEqualTo(mockMember.getId()); - assertThat(response.writer().nickname()).isEqualTo(mockMember.getNickname()); + assertThat(response.getContent().get(0).answerId()).isEqualTo(80L); + assertThat(response.getContent().get(0).isEmojied()).isFalse(); + } + + @Test + @DisplayName("답변 목록 조회 - 공감이 포함된 답변들 조회") + void getAnswers_containsEmojiedAnswers() { + // given + List answers = List.of(createAnswer(1L,LocalDateTime.now()), createAnswer(2L,LocalDateTime.now())); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(1L, 2L))) + .willReturn(Set.of(2L)); + + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); + + // then + assertThat(response.getContent().get(0).isEmojied()).isFalse(); + assertThat(response.getContent().get(1).isEmojied()).isTrue(); + } + + + @Test + @DisplayName("답변 목록 조회 - 결과가 비어도 예외 없이 처리") + void getAnswers_emptyList_noError() { + // given + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(List.of()); + + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); + + // then + assertThat(response.getContent()).asInstanceOf(LIST).isEmpty(); + } + + private Answer createAnswer(Long id, LocalDateTime createdAt) { + Answer answer = Answer.builder() + .id(id) + .member(mockMember) + .question(mockQuestion) + .content("답변입니다") + .emojiCount(0L) + .build(); + + ReflectionTestUtils.setField(answer, "createdAt", createdAt); + return answer; } @Test From b0bb3b1a85bbddf756da4b27679a8047050cdc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:12:55 +0900 Subject: [PATCH 32/74] =?UTF-8?q?feat:=20cors=20=EC=84=A4=EC=A0=95=20(#102?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/member/security/SecurityConfig.java | 20 ++++++++++++++++++- .../handshake/CustomHandshakeHandler.java | 1 - 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 582dac7..8332001 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -2,6 +2,8 @@ import static org.springframework.security.config.Customizer.*; +import java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -9,6 +11,9 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.RequiredArgsConstructor; @@ -23,7 +28,7 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) - .cors(cors -> cors.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/guest", @@ -51,4 +56,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowCredentials(true); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java index 2b5b78a..9910dae 100644 --- a/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java @@ -58,5 +58,4 @@ protected Principal determineUser(ServerHttpRequest request, WebSocketHandler ws // fallback 경로로 전송 return null; } - } From e888ed5876d39d7f6d5a7e67a0f17a063e8ef650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:13:47 +0900 Subject: [PATCH 33/74] =?UTF-8?q?release:=20=EC=BD=94=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/member/security/SecurityConfig.java | 20 ++++++++++++++++++- .../handshake/CustomHandshakeHandler.java | 1 - 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 582dac7..8332001 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -2,6 +2,8 @@ import static org.springframework.security.config.Customizer.*; +import java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -9,6 +11,9 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.RequiredArgsConstructor; @@ -23,7 +28,7 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) - .cors(cors -> cors.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/guest", @@ -51,4 +56,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowCredentials(true); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java index 2b5b78a..9910dae 100644 --- a/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java @@ -58,5 +58,4 @@ protected Principal determineUser(ServerHttpRequest request, WebSocketHandler ws // fallback 경로로 전송 return null; } - } From 12ad4ba23abfec9c507308c2af1dff11bb63a1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:17:49 +0900 Subject: [PATCH 34/74] Refactor/101 room detail (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 --- .../join/websocket/config/WebSocketConfig.java | 10 +++++----- .../session/CurrentParticipantEventHandler.java | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index a2afca6..797a0c9 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -9,7 +9,6 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; @@ -48,15 +47,16 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - .setHandshakeHandler(handshakeHandler) + // .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - .addInterceptors(new HttpSessionHandshakeInterceptor()) - .setHandshakeHandler(handshakeHandler); + // .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .setHandshakeHandler(handshakeHandler) + ; registry.setErrorHandler(stompErrorHandler); } diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c6b25c2..83d9bf3 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,7 +2,6 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; -import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; From 3adeb88c4f24de7280acb5ac2dbe2f77f4af2491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:18:14 +0900 Subject: [PATCH 35/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/websocket/config/WebSocketConfig.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index a2afca6..797a0c9 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -9,7 +9,6 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; @@ -48,15 +47,16 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - .setHandshakeHandler(handshakeHandler) + // .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - .addInterceptors(new HttpSessionHandshakeInterceptor()) - .setHandshakeHandler(handshakeHandler); + // .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .setHandshakeHandler(handshakeHandler) + ; registry.setErrorHandler(stompErrorHandler); } From e9fdf79a055b97226b5b5ff536737a2cbc8f01fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:47:35 +0900 Subject: [PATCH 36/74] Refactor/101 room detail (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 --- .../api/QuestionWebsocketController.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 7186fb2..a0fb291 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -1,5 +1,13 @@ package com.oronaminc.join.websocket.api; +import java.security.Principal; + +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; @@ -11,16 +19,11 @@ import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.question.service.QuestionService; import com.oronaminc.join.question.util.QuestionMapper; + import io.github.bucket4j.Bucket; import jakarta.validation.Valid; -import java.security.Principal; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.stereotype.Controller; @Slf4j @Controller @@ -37,8 +40,12 @@ public QuestionCreateResponse createQuestion( @Payload @Valid QuestionRequest request, Principal principal ) { + log.debug("수신한 메시지 = {}", request.content()); + Long memberId = Long.valueOf(principal.getName()); + log.debug("회원 아이디 = {}", memberId); + Bucket bucket = rateLimitService.getBucket(RateLimitType.CREATE_QUESTION, roomId, memberId); if (!bucket.tryConsume(1)) { @@ -47,7 +54,6 @@ public QuestionCreateResponse createQuestion( Question question = questionService.create(roomId, memberId, request); - log.info("수신한 메시지 = {}", request.content()); return QuestionMapper.toQuestionCreateResponse(question); } From 03c6b84bb2ee709d29189bccb35eac59f6fcdadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:47:59 +0900 Subject: [PATCH 37/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../api/QuestionWebsocketController.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 7186fb2..a0fb291 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -1,5 +1,13 @@ package com.oronaminc.join.websocket.api; +import java.security.Principal; + +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; @@ -11,16 +19,11 @@ import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.question.service.QuestionService; import com.oronaminc.join.question.util.QuestionMapper; + import io.github.bucket4j.Bucket; import jakarta.validation.Valid; -import java.security.Principal; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.stereotype.Controller; @Slf4j @Controller @@ -37,8 +40,12 @@ public QuestionCreateResponse createQuestion( @Payload @Valid QuestionRequest request, Principal principal ) { + log.debug("수신한 메시지 = {}", request.content()); + Long memberId = Long.valueOf(principal.getName()); + log.debug("회원 아이디 = {}", memberId); + Bucket bucket = rateLimitService.getBucket(RateLimitType.CREATE_QUESTION, roomId, memberId); if (!bucket.tryConsume(1)) { @@ -47,7 +54,6 @@ public QuestionCreateResponse createQuestion( Question question = questionService.create(roomId, memberId, request); - log.info("수신한 메시지 = {}", request.content()); return QuestionMapper.toQuestionCreateResponse(question); } From bf2a94247a3961da06d4859cc795a0c65f2647c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:55:48 +0900 Subject: [PATCH 38/74] Refactor/101 room detail (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --- .../oronaminc/join/websocket/config/WebSocketConfig.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index 797a0c9..bbee92f 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -47,15 +47,15 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - // .addInterceptors(new HttpSessionHandshakeInterceptor()) + .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - // .setHandshakeHandler(handshakeHandler) + .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - // .addInterceptors(new HttpSessionHandshakeInterceptor()) - // .setHandshakeHandler(handshakeHandler) + .addInterceptors(new HttpSessionHandshakeInterceptor()) + .setHandshakeHandler(handshakeHandler) ; registry.setErrorHandler(stompErrorHandler); From fbac2dd5f839d34ac0e331f9c0102e3f572bd23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:56:52 +0900 Subject: [PATCH 39/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../oronaminc/join/websocket/config/WebSocketConfig.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index 797a0c9..bbee92f 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -47,15 +47,15 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - // .addInterceptors(new HttpSessionHandshakeInterceptor()) + .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - // .setHandshakeHandler(handshakeHandler) + .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - // .addInterceptors(new HttpSessionHandshakeInterceptor()) - // .setHandshakeHandler(handshakeHandler) + .addInterceptors(new HttpSessionHandshakeInterceptor()) + .setHandshakeHandler(handshakeHandler) ; registry.setErrorHandler(stompErrorHandler); From 790be9ab7a8b4c3020b0afa3961ee0349b1a85d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:01:56 +0900 Subject: [PATCH 40/74] Refactor/101 room detail (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 From d9d34dfb8c748cbab591848989c3926fb6b560d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:02:30 +0900 Subject: [PATCH 41/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=B3=91=ED=95=A9=20(#?= =?UTF-8?q?111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> From 439c39f197b8ac366046e20a8e425c3656970f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:05:04 +0900 Subject: [PATCH 42/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A91?= =?UTF-8?q?=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> From 8145fa0c6ee43846feae1cd78421ed7797947688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:13:21 +0900 Subject: [PATCH 43/74] Update WebSocketConfig.java --- .../com/oronaminc/join/websocket/config/WebSocketConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index bbee92f..be7c913 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -9,6 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; From d7263efa34ddde06da4a702495cd99c8eb26c130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:14:01 +0900 Subject: [PATCH 44/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../com/oronaminc/join/websocket/config/WebSocketConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index bbee92f..be7c913 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -9,6 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; From 129d08ff42a0fa85e40cb74f506ed318a07169ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:13:02 +0900 Subject: [PATCH 45/74] Refactor/101 room detail (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --- .../api/QuestionWebsocketController.java | 6 ++- .../config/StompAuthChannelInterceptor.java | 29 ++++++++++++ .../websocket/config/WebSocketConfig.java | 16 ++++--- .../CurrentParticipantEventHandler.java | 45 +++++++++---------- 4 files changed, 66 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index a0fb291..773828b 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -6,12 +6,14 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; +import com.oronaminc.join.member.security.MemberDetails; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.dto.QuestionCreateResponse; import com.oronaminc.join.question.dto.QuestionDeleteResponse; @@ -42,7 +44,8 @@ public QuestionCreateResponse createQuestion( ) { log.debug("수신한 메시지 = {}", request.content()); - Long memberId = Long.valueOf(principal.getName()); + MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); + Long memberId = Long.valueOf(memberDetails.getId()); log.debug("회원 아이디 = {}", memberId); @@ -66,6 +69,7 @@ public QuestionUpdateResponse updateQuestion( @Payload @Valid QuestionRequest request, Principal principal ) { + Long memberId = Long.valueOf(principal.getName()); Question updated = questionService.update(memberId, roomId, questionId, request); diff --git a/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java b/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java new file mode 100644 index 0000000..0cc944d --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java @@ -0,0 +1,29 @@ +package com.oronaminc.join.websocket.config; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class StompAuthChannelInterceptor implements ChannelInterceptor { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.isAuthenticated()) { + accessor.setUser(authentication); + } + } + + return message; + } +} diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index be7c913..5c8c2ad 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -3,13 +3,13 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; @@ -27,6 +27,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompErrorHandler stompErrorHandler; private final WebsocketSessionManager sessionManager; private final ApplicationEventPublisher publisher; + private final StompAuthChannelInterceptor stompAuthChannelInterceptor; @Bean public WebSocketHandlerDecoratorFactory webSocketHandlerDecoratorFactory( @@ -48,15 +49,15 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - .setHandshakeHandler(handshakeHandler) + // .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - .addInterceptors(new HttpSessionHandshakeInterceptor()) - .setHandshakeHandler(handshakeHandler) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .setHandshakeHandler(handshakeHandler) ; registry.setErrorHandler(stompErrorHandler); @@ -66,4 +67,9 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { public void configureWebSocketTransport(WebSocketTransportRegistration registry) { registry.setDecoratorFactories(webSocketHandlerDecoratorFactory(sessionManager, publisher)); } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompAuthChannelInterceptor); + } } diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c6b25c2..806c75d 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,14 +2,11 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; -import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; import org.springframework.context.event.EventListener; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; -import org.springframework.web.socket.messaging.SessionSubscribeEvent; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.room.event.RoomExitEvent; @@ -24,27 +21,27 @@ public class CurrentParticipantEventHandler { private static final String ROOM_PREFIX = "/topic/rooms/"; private static final String JOIN_SUFFIX = "/join"; - @EventListener - public void handleSubscribe(SessionSubscribeEvent event) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - String destination = accessor.getDestination(); - Principal principal = accessor.getUser(); - - if (destination == null) { - throw new ErrorException(STOMP_INVALID_DESTINATION); - } - - if (!destination.startsWith(ROOM_PREFIX)) { - return; - } - - Long memberId = parseMemberId(principal); - Long roomId = parseRoomId(destination); - - if (!isRoomJoinPath(destination)) { - validateParticipantRoomJoin(roomId, memberId); - } - } + // @EventListener + // public void handleSubscribe(SessionSubscribeEvent event) { + // StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + // String destination = accessor.getDestination(); + // Principal principal = accessor.getUser(); + // + // if (destination == null) { + // throw new ErrorException(STOMP_INVALID_DESTINATION); + // } + // + // if (!destination.startsWith(ROOM_PREFIX)) { + // return; + // } + // + // Long memberId = parseMemberId(principal); + // Long roomId = parseRoomId(destination); + // + // if (!isRoomJoinPath(destination)) { + // validateParticipantRoomJoin(roomId, memberId); + // } + // } @EventListener public void handleUnsubscribe(RoomExitEvent event) { From 56237277abc1d0d60d8e4deb6c23bffc13453239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:14:31 +0900 Subject: [PATCH 46/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=B3=91=ED=95=A9=20(#?= =?UTF-8?q?115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../api/QuestionWebsocketController.java | 6 ++- .../config/StompAuthChannelInterceptor.java | 29 ++++++++++++ .../websocket/config/WebSocketConfig.java | 7 +++ .../CurrentParticipantEventHandler.java | 45 +++++++++---------- 4 files changed, 62 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index a0fb291..773828b 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -6,12 +6,14 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; +import com.oronaminc.join.member.security.MemberDetails; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.dto.QuestionCreateResponse; import com.oronaminc.join.question.dto.QuestionDeleteResponse; @@ -42,7 +44,8 @@ public QuestionCreateResponse createQuestion( ) { log.debug("수신한 메시지 = {}", request.content()); - Long memberId = Long.valueOf(principal.getName()); + MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); + Long memberId = Long.valueOf(memberDetails.getId()); log.debug("회원 아이디 = {}", memberId); @@ -66,6 +69,7 @@ public QuestionUpdateResponse updateQuestion( @Payload @Valid QuestionRequest request, Principal principal ) { + Long memberId = Long.valueOf(principal.getName()); Question updated = questionService.update(memberId, roomId, questionId, request); diff --git a/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java b/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java new file mode 100644 index 0000000..0cc944d --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java @@ -0,0 +1,29 @@ +package com.oronaminc.join.websocket.config; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class StompAuthChannelInterceptor implements ChannelInterceptor { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.isAuthenticated()) { + accessor.setUser(authentication); + } + } + + return message; + } +} diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index be7c913..e05957c 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -3,6 +3,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; @@ -27,6 +28,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompErrorHandler stompErrorHandler; private final WebsocketSessionManager sessionManager; private final ApplicationEventPublisher publisher; + private final StompAuthChannelInterceptor stompAuthChannelInterceptor; @Bean public WebSocketHandlerDecoratorFactory webSocketHandlerDecoratorFactory( @@ -66,4 +68,9 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { public void configureWebSocketTransport(WebSocketTransportRegistration registry) { registry.setDecoratorFactories(webSocketHandlerDecoratorFactory(sessionManager, publisher)); } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompAuthChannelInterceptor); + } } diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c6b25c2..806c75d 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,14 +2,11 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; -import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; import org.springframework.context.event.EventListener; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; -import org.springframework.web.socket.messaging.SessionSubscribeEvent; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.room.event.RoomExitEvent; @@ -24,27 +21,27 @@ public class CurrentParticipantEventHandler { private static final String ROOM_PREFIX = "/topic/rooms/"; private static final String JOIN_SUFFIX = "/join"; - @EventListener - public void handleSubscribe(SessionSubscribeEvent event) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - String destination = accessor.getDestination(); - Principal principal = accessor.getUser(); - - if (destination == null) { - throw new ErrorException(STOMP_INVALID_DESTINATION); - } - - if (!destination.startsWith(ROOM_PREFIX)) { - return; - } - - Long memberId = parseMemberId(principal); - Long roomId = parseRoomId(destination); - - if (!isRoomJoinPath(destination)) { - validateParticipantRoomJoin(roomId, memberId); - } - } + // @EventListener + // public void handleSubscribe(SessionSubscribeEvent event) { + // StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + // String destination = accessor.getDestination(); + // Principal principal = accessor.getUser(); + // + // if (destination == null) { + // throw new ErrorException(STOMP_INVALID_DESTINATION); + // } + // + // if (!destination.startsWith(ROOM_PREFIX)) { + // return; + // } + // + // Long memberId = parseMemberId(principal); + // Long roomId = parseRoomId(destination); + // + // if (!isRoomJoinPath(destination)) { + // validateParticipantRoomJoin(roomId, memberId); + // } + // } @EventListener public void handleUnsubscribe(RoomExitEvent event) { From ab1ebb5f713df1edf1117e48a3406a02942511ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:26:52 +0900 Subject: [PATCH 47/74] Refactor/101 room detail (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --- .../java/com/oronaminc/join/member/security/SecurityConfig.java | 2 +- .../join/websocket/api/QuestionWebsocketController.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 8332001..42067b2 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -61,7 +61,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); - configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedOriginPatterns(List.of("http://localhost:5173")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 773828b..076eea9 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -43,6 +43,7 @@ public QuestionCreateResponse createQuestion( Principal principal ) { log.debug("수신한 메시지 = {}", request.content()); + log.debug("principal = {}", principal); MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); Long memberId = Long.valueOf(memberDetails.getId()); From bb7ea678e99006472301ed64f3a508d8c334b493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:28:29 +0900 Subject: [PATCH 48/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../java/com/oronaminc/join/member/security/SecurityConfig.java | 2 +- .../join/websocket/api/QuestionWebsocketController.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 8332001..42067b2 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -61,7 +61,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); - configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedOriginPatterns(List.of("http://localhost:5173")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 773828b..076eea9 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -43,6 +43,7 @@ public QuestionCreateResponse createQuestion( Principal principal ) { log.debug("수신한 메시지 = {}", request.content()); + log.debug("principal = {}", principal); MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); Long memberId = Long.valueOf(memberDetails.getId()); From a4c92d4a9620069fe7422c46f75b81299375b8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:46:45 +0900 Subject: [PATCH 49/74] Refactor/101 room detail (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 --- .../com/oronaminc/join/websocket/config/WebSocketConfig.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index 5c8c2ad..90f8bd3 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -10,6 +10,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; @@ -49,9 +50,9 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - // .addInterceptors(new HttpSessionHandshakeInterceptor()) + .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - // .setHandshakeHandler(handshakeHandler) + .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") From 1c6bcae3666b05786af4ebfe224c731a0ed99687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:47:09 +0900 Subject: [PATCH 50/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> From 4ab7101ff1365160c90736c7503d4f39fe17abfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:05:08 +0900 Subject: [PATCH 51/74] Refactor/101 room detail (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 --- .../com/oronaminc/join/websocket/config/WebSocketConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index 90f8bd3..64913c2 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -55,8 +55,8 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { .setHandshakeHandler(handshakeHandler) .withSockJS(); - registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*") + // registry.addEndpoint("/ws") + // .setAllowedOriginPatterns("*") // .addInterceptors(new HttpSessionHandshakeInterceptor()) // .setHandshakeHandler(handshakeHandler) ; From 8c5430935202263524ad35443a150b807fce3dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:06:39 +0900 Subject: [PATCH 52/74] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/websocket/config/WebSocketConfig.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index e05957c..60eb104 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -50,16 +50,17 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - .setHandshakeHandler(handshakeHandler) + // .setHandshakeHandler(handshakeHandler) .withSockJS(); - registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*") - .addInterceptors(new HttpSessionHandshakeInterceptor()) - .setHandshakeHandler(handshakeHandler) - ; + // registry.addEndpoint("/ws") + // .setAllowedOriginPatterns("*") + // .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .setHandshakeHandler(handshakeHandler) + + //; registry.setErrorHandler(stompErrorHandler); } From f5e084b2cc4f9f2dbffa00c52e2ebe93747b58e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:30:56 +0900 Subject: [PATCH 53/74] =?UTF-8?q?test:=20=EC=A7=88=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 --- .../com/oronaminc/join/member/security/SecurityConfig.java | 2 +- .../com/oronaminc/join/question/dto/QuestionRequest.java | 3 ++- .../join/websocket/api/QuestionWebsocketController.java | 5 +---- .../com/oronaminc/join/websocket/config/WebSocketConfig.java | 1 - 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 42067b2..8332001 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -61,7 +61,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); - configuration.setAllowedOriginPatterns(List.of("http://localhost:5173")); + configuration.setAllowedOriginPatterns(List.of("*")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java b/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java index c5c6772..d4f28e3 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java @@ -9,7 +9,8 @@ public record QuestionRequest( @Schema(description = "질문 내용", example = "질문있습니다. 질문생성DTO가 맞나요?") @NotBlank(message = "질문 내용을 입력해주시기 바랍니다.") @Size(max = 500, message = "질문 내용은 최대 500자까지 입력할 수 있습니다.") - String content + String content, + Long memberId ) { } diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 076eea9..c480843 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -6,14 +6,12 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; -import com.oronaminc.join.member.security.MemberDetails; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.dto.QuestionCreateResponse; import com.oronaminc.join.question.dto.QuestionDeleteResponse; @@ -45,8 +43,7 @@ public QuestionCreateResponse createQuestion( log.debug("수신한 메시지 = {}", request.content()); log.debug("principal = {}", principal); - MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); - Long memberId = Long.valueOf(memberDetails.getId()); + Long memberId = request.memberId(); log.debug("회원 아이디 = {}", memberId); diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index 60eb104..f529f45 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -10,7 +10,6 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; From 1fcebe40f2eb2c614939dc83e1a546acaf0c039e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:31:38 +0900 Subject: [PATCH 54/74] =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=91=ED=95=A9=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../com/oronaminc/join/member/security/SecurityConfig.java | 2 +- .../com/oronaminc/join/question/dto/QuestionRequest.java | 3 ++- .../join/websocket/api/QuestionWebsocketController.java | 5 +---- .../com/oronaminc/join/websocket/config/WebSocketConfig.java | 1 - 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 42067b2..8332001 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -61,7 +61,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); - configuration.setAllowedOriginPatterns(List.of("http://localhost:5173")); + configuration.setAllowedOriginPatterns(List.of("*")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java b/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java index c5c6772..d4f28e3 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionRequest.java @@ -9,7 +9,8 @@ public record QuestionRequest( @Schema(description = "질문 내용", example = "질문있습니다. 질문생성DTO가 맞나요?") @NotBlank(message = "질문 내용을 입력해주시기 바랍니다.") @Size(max = 500, message = "질문 내용은 최대 500자까지 입력할 수 있습니다.") - String content + String content, + Long memberId ) { } diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 076eea9..c480843 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -6,14 +6,12 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; -import com.oronaminc.join.member.security.MemberDetails; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.dto.QuestionCreateResponse; import com.oronaminc.join.question.dto.QuestionDeleteResponse; @@ -45,8 +43,7 @@ public QuestionCreateResponse createQuestion( log.debug("수신한 메시지 = {}", request.content()); log.debug("principal = {}", principal); - MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); - Long memberId = Long.valueOf(memberDetails.getId()); + Long memberId = request.memberId(); log.debug("회원 아이디 = {}", memberId); diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index 60eb104..f529f45 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -10,7 +10,6 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; From e3d968720a6c7cc15c66154a38794ac053ac79dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:09:55 +0900 Subject: [PATCH 55/74] =?UTF-8?q?fix:=20stomp=20controller=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 --- .../join/answer/dto/AnswerRequest.java | 5 ++- .../join/emoji/dto/EmojiRequest.java | 4 +- .../api/AnswerWebsocketController.java | 39 +++++++---------- .../api/EmojiWebsocketController.java | 23 +++++----- .../api/QuestionWebsocketController.java | 15 +++---- .../api/RoomWebsocketController.java | 6 +-- .../websocket/api/StompMemberRequest.java | 9 ++++ .../answer/service/AnswerServiceTests.java | 43 +++++++++---------- .../join/emoji/service/EmojiFacadeTests.java | 4 +- .../join/emoji/service/EmojiServiceTests.java | 38 ++++++++-------- .../service/QuestionServiceTests.java | 42 +++++++++--------- 11 files changed, 111 insertions(+), 117 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java index 9d7366d..5646c80 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @Schema(description = "답변 생성/수정 요청 DTO") @@ -9,7 +10,9 @@ public record AnswerRequest( @NotBlank(message = "답변 내용을 입력해주시기 바랍니다.") @Size(max = 300, message = "답변 내용은 최대 300자까지 입력할 수 있습니다.") @Schema(description = "답변 내용", example = "답변입니다.") - String content + String content, + @NotNull + Long memberId ) { } diff --git a/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java b/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java index 1b8b688..e72a036 100644 --- a/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java +++ b/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java @@ -11,7 +11,9 @@ public record EmojiRequest( TargetType targetType, @NotNull @Schema(description = "공감 대상 ID", example = "1") - Long targetId + Long targetId, + @NotNull + Long memberId ) { } diff --git a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java index 12350e4..13c13fa 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java @@ -1,7 +1,12 @@ package com.oronaminc.join.websocket.api; -import static com.oronaminc.join.global.exception.ErrorCode.TOO_MANY_REQUESTS_ANSWER; -import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_MEMBER; +import static com.oronaminc.join.global.exception.ErrorCode.*; + +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.dto.AnswerCreateResponse; @@ -11,19 +16,14 @@ import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.websocket.common.EventType; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; +import com.oronaminc.join.websocket.common.EventType; + import io.github.bucket4j.Bucket; import jakarta.validation.Valid; -import java.security.Principal; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.stereotype.Controller; @Slf4j @Controller @@ -38,10 +38,9 @@ public class AnswerWebsocketController { public AnswerCreateResponse create( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - @Payload @Valid AnswerRequest request, - Principal principal + @Payload @Valid AnswerRequest request ) { - Long memberId = getMemberId(principal); + Long memberId = request.memberId(); Bucket bucket = rateLimitService.getBucket(RateLimitType.CREATE_ANSWER, roomId, memberId, questionId); @@ -60,11 +59,10 @@ public AnswerCreateResponse create( @SendTo("/topic/rooms/{roomId}/answers") public AnswerUpdateResponse update( @DestinationVariable Long answerId, - @Payload @Valid AnswerRequest request, - Principal principal + @Payload @Valid AnswerRequest request ) { - Long memberId = getMemberId(principal); + Long memberId = request.memberId(); Answer answer = answerService.update(answerId, memberId, request); @@ -77,9 +75,9 @@ public AnswerUpdateResponse update( @SendTo("/topic/rooms/{roomId}/answers") public AnswerDeleteResponse delete( @DestinationVariable Long answerId, - Principal principal + @Payload @Valid StompMemberRequest request ) { - Long memberId = getMemberId(principal); + Long memberId = request.memberId(); answerService.delete(answerId, memberId); @@ -88,11 +86,4 @@ public AnswerDeleteResponse delete( return new AnswerDeleteResponse(answerId, EventType.DELETE); } - private Long getMemberId(Principal principal) { - if (principal == null) { - throw new ErrorException(UNAUTHORIZED_MEMBER); - } - return Long.valueOf(principal.getName()); - } - } diff --git a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java index 32716aa..2e29715 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java @@ -1,5 +1,11 @@ package com.oronaminc.join.websocket.api; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; import com.oronaminc.join.emoji.service.EmojiFacade; @@ -7,15 +13,10 @@ import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; + import io.github.bucket4j.Bucket; import jakarta.validation.Valid; -import java.security.Principal; import lombok.RequiredArgsConstructor; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.stereotype.Controller; @Controller @RequiredArgsConstructor @@ -28,10 +29,9 @@ public class EmojiWebsocketController { @SendTo("/topic/rooms/{roomId}/emojis") public EmojiResponse createEmoji( @DestinationVariable Long roomId, - @Payload @Valid EmojiRequest emojiRequest, - Principal principal + @Payload @Valid EmojiRequest emojiRequest ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = emojiRequest.memberId(); Bucket bucket = rateLimitService.getBucket(RateLimitType.EMOJI, memberId, emojiRequest.targetType(), emojiRequest.targetId()); @@ -47,10 +47,9 @@ public EmojiResponse createEmoji( @SendTo("/topic/rooms/{roomId}/emojis") public EmojiResponse deleteEmoji( @DestinationVariable Long roomId, - @Payload @Valid EmojiRequest emojiRequest, - Principal principal + @Payload @Valid EmojiRequest emojiRequest ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = emojiRequest.memberId(); Bucket bucket = rateLimitService.getBucket(RateLimitType.EMOJI, memberId, emojiRequest.targetType(), emojiRequest.targetId()); diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index c480843..6f0e73e 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -1,7 +1,5 @@ package com.oronaminc.join.websocket.api; -import java.security.Principal; - import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; @@ -37,11 +35,9 @@ public class QuestionWebsocketController { @SendTo("/topic/rooms/{roomId}/questions") public QuestionCreateResponse createQuestion( @DestinationVariable Long roomId, - @Payload @Valid QuestionRequest request, - Principal principal + @Payload @Valid QuestionRequest request ) { log.debug("수신한 메시지 = {}", request.content()); - log.debug("principal = {}", principal); Long memberId = request.memberId(); @@ -64,11 +60,10 @@ public QuestionCreateResponse createQuestion( public QuestionUpdateResponse updateQuestion( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - @Payload @Valid QuestionRequest request, - Principal principal + @Payload @Valid QuestionRequest request ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = request.memberId(); Question updated = questionService.update(memberId, roomId, questionId, request); @@ -80,9 +75,9 @@ public QuestionUpdateResponse updateQuestion( public QuestionDeleteResponse deleteQuestion( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - Principal principal + @Payload @Valid StompMemberRequest request ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = request.memberId(); Long deletedId = questionService.delete(memberId, roomId, questionId); diff --git a/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java index a6221de..e4fe0e2 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java @@ -1,7 +1,5 @@ package com.oronaminc.join.websocket.api; -import java.security.Principal; - import org.springframework.messaging.Message; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -25,10 +23,10 @@ public class RoomWebsocketController { @SendTo("/topic/rooms/{roomId}/join") public RoomJoinResponse joinRoom( @DestinationVariable Long roomId, - Principal principal, + StompMemberRequest request, Message message ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = request.memberId(); StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); String sessionId = accessor.getSessionId(); diff --git a/src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java b/src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java new file mode 100644 index 0000000..a353e20 --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java @@ -0,0 +1,9 @@ +package com.oronaminc.join.websocket.api; + +import jakarta.validation.constraints.NotNull; + +public record StompMemberRequest( + @NotNull + Long memberId +) { +} diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index 9571356..126b86e 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -1,13 +1,24 @@ package com.oronaminc.join.answer.service; -import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_ROOM; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.assertj.core.api.InstanceOfAssertFactories.LIST; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; +import static com.oronaminc.join.global.exception.ErrorCode.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.InstanceOfAssertFactories.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Slice; +import org.springframework.test.util.ReflectionTestUtils; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; @@ -31,18 +42,6 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Slice; -import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class AnswerServiceTests { @@ -114,7 +113,7 @@ void setUp() { .participantType(ParticipantType.TEAM) .build(); - request = new AnswerRequest("답변입니다."); + request = new AnswerRequest("답변입니다.", mockMember.getId()); } @Test @@ -258,7 +257,7 @@ void updateAnswer_success() { given(permissionValidator.validateAnswerUpdatePermission(1L, 1L)) .willReturn(answer); - AnswerRequest request = new AnswerRequest("수정된 내용"); + AnswerRequest request = new AnswerRequest("수정된 내용", 1L); // when Answer result = answerService.update(answer.getId(), answer.getMember().getId(), request); diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java index b0c727e..5c6192f 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java @@ -89,7 +89,7 @@ void createEmoji_success_test() throws InterruptedException { executorService.submit(() -> { try { emojiFacade.createEmoji(members.get(idx).getId(), - new EmojiRequest(TargetType.ROOM, roomId)); + new EmojiRequest(TargetType.ROOM, roomId, members.get(idx).getId())); } catch (Exception e) { e.printStackTrace(); } finally { @@ -147,7 +147,7 @@ void deleteEmoji_success_test() throws InterruptedException { executorService.submit(() -> { try { emojiFacade.deleteEmoji(members.get(idx).getId(), - new EmojiRequest(TargetType.ROOM, roomId)); + new EmojiRequest(TargetType.ROOM, roomId, members.get(idx).getId())); } catch (Exception e) { e.printStackTrace(); } finally { diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java index e369c8d..c6e1ecb 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java @@ -1,9 +1,16 @@ package com.oronaminc.join.emoji.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; @@ -21,13 +28,6 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; import com.oronaminc.join.websocket.common.EventType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class EmojiServiceTests { @@ -81,7 +81,7 @@ void toggleEmoji_createRoomEmoji_success() { // when EmojiResponse response = emojiService.createEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.CREATE); @@ -119,7 +119,7 @@ void toggleEmoji_createQuestionEmoji_success() { // when EmojiResponse response = emojiService.createEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.CREATE); @@ -157,7 +157,7 @@ void toggleEmoji_createAnswerEmoji_success() { // when EmojiResponse response = emojiService.createEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.CREATE); @@ -194,7 +194,7 @@ void toggleEmoji_createRoomEmoji_fail() { // then assertThatThrownBy( () -> { - emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId)); + emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId, memberId)); } ).isInstanceOf(ErrorException.class); @@ -226,7 +226,7 @@ void toggleEmoji_deleteRoomEmoji_success() { // when EmojiResponse response = emojiService.deleteEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.DELETE); @@ -262,7 +262,7 @@ void toggleEmoji_deleteQuestionEmoji_success() { // when EmojiResponse response = emojiService.deleteEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.DELETE); @@ -298,7 +298,7 @@ void toggleEmoji_deleteAnswerEmoji_success() { // when EmojiResponse response = emojiService.deleteEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.DELETE); @@ -335,7 +335,7 @@ void toggleEmoji_deleteRoomEmoji_fail() { // then assertThatThrownBy( () -> { - emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId)); + emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId, memberId)); } ).isInstanceOf(ErrorException.class); diff --git a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java index d37c894..f8c30d4 100644 --- a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java +++ b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java @@ -1,13 +1,23 @@ package com.oronaminc.join.question.service; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.doNothing; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.verify; -import static org.mockito.BDDMockito.willThrow; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorCode; @@ -26,20 +36,8 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; -import java.time.LocalDateTime; -import java.util.List; + import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.test.context.ActiveProfiles; @Slf4j @ActiveProfiles("test") @@ -93,7 +91,7 @@ void setUp() { .roomStatus(RoomStatus.STARTED) .build(); - request = new QuestionRequest("질문입니다"); + request = new QuestionRequest("질문입니다", mockMember.getId()); mockQ1 = QuestionFlatResponse.builder() .questionId(1L) From 362c116265de940a650f90669bd2fdd66f591969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:11:34 +0900 Subject: [PATCH 56/74] =?UTF-8?q?release:=20stomp=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=88=98=EC=A0=95=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/answer/dto/AnswerRequest.java | 5 ++- .../join/emoji/dto/EmojiRequest.java | 4 +- .../api/AnswerWebsocketController.java | 39 +++++++---------- .../api/EmojiWebsocketController.java | 23 +++++----- .../api/QuestionWebsocketController.java | 15 +++---- .../api/RoomWebsocketController.java | 6 +-- .../websocket/api/StompMemberRequest.java | 9 ++++ .../answer/service/AnswerServiceTests.java | 43 +++++++++---------- .../join/emoji/service/EmojiFacadeTests.java | 4 +- .../join/emoji/service/EmojiServiceTests.java | 38 ++++++++-------- .../service/QuestionServiceTests.java | 42 +++++++++--------- 11 files changed, 111 insertions(+), 117 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java index 9d7366d..5646c80 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerRequest.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @Schema(description = "답변 생성/수정 요청 DTO") @@ -9,7 +10,9 @@ public record AnswerRequest( @NotBlank(message = "답변 내용을 입력해주시기 바랍니다.") @Size(max = 300, message = "답변 내용은 최대 300자까지 입력할 수 있습니다.") @Schema(description = "답변 내용", example = "답변입니다.") - String content + String content, + @NotNull + Long memberId ) { } diff --git a/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java b/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java index 1b8b688..e72a036 100644 --- a/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java +++ b/src/main/java/com/oronaminc/join/emoji/dto/EmojiRequest.java @@ -11,7 +11,9 @@ public record EmojiRequest( TargetType targetType, @NotNull @Schema(description = "공감 대상 ID", example = "1") - Long targetId + Long targetId, + @NotNull + Long memberId ) { } diff --git a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java index 12350e4..13c13fa 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java @@ -1,7 +1,12 @@ package com.oronaminc.join.websocket.api; -import static com.oronaminc.join.global.exception.ErrorCode.TOO_MANY_REQUESTS_ANSWER; -import static com.oronaminc.join.global.exception.ErrorCode.UNAUTHORIZED_MEMBER; +import static com.oronaminc.join.global.exception.ErrorCode.*; + +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.dto.AnswerCreateResponse; @@ -11,19 +16,14 @@ import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorException; -import com.oronaminc.join.websocket.common.EventType; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; +import com.oronaminc.join.websocket.common.EventType; + import io.github.bucket4j.Bucket; import jakarta.validation.Valid; -import java.security.Principal; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.stereotype.Controller; @Slf4j @Controller @@ -38,10 +38,9 @@ public class AnswerWebsocketController { public AnswerCreateResponse create( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - @Payload @Valid AnswerRequest request, - Principal principal + @Payload @Valid AnswerRequest request ) { - Long memberId = getMemberId(principal); + Long memberId = request.memberId(); Bucket bucket = rateLimitService.getBucket(RateLimitType.CREATE_ANSWER, roomId, memberId, questionId); @@ -60,11 +59,10 @@ public AnswerCreateResponse create( @SendTo("/topic/rooms/{roomId}/answers") public AnswerUpdateResponse update( @DestinationVariable Long answerId, - @Payload @Valid AnswerRequest request, - Principal principal + @Payload @Valid AnswerRequest request ) { - Long memberId = getMemberId(principal); + Long memberId = request.memberId(); Answer answer = answerService.update(answerId, memberId, request); @@ -77,9 +75,9 @@ public AnswerUpdateResponse update( @SendTo("/topic/rooms/{roomId}/answers") public AnswerDeleteResponse delete( @DestinationVariable Long answerId, - Principal principal + @Payload @Valid StompMemberRequest request ) { - Long memberId = getMemberId(principal); + Long memberId = request.memberId(); answerService.delete(answerId, memberId); @@ -88,11 +86,4 @@ public AnswerDeleteResponse delete( return new AnswerDeleteResponse(answerId, EventType.DELETE); } - private Long getMemberId(Principal principal) { - if (principal == null) { - throw new ErrorException(UNAUTHORIZED_MEMBER); - } - return Long.valueOf(principal.getName()); - } - } diff --git a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java index 32716aa..2e29715 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/EmojiWebsocketController.java @@ -1,5 +1,11 @@ package com.oronaminc.join.websocket.api; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; import com.oronaminc.join.emoji.service.EmojiFacade; @@ -7,15 +13,10 @@ import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; + import io.github.bucket4j.Bucket; import jakarta.validation.Valid; -import java.security.Principal; import lombok.RequiredArgsConstructor; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.stereotype.Controller; @Controller @RequiredArgsConstructor @@ -28,10 +29,9 @@ public class EmojiWebsocketController { @SendTo("/topic/rooms/{roomId}/emojis") public EmojiResponse createEmoji( @DestinationVariable Long roomId, - @Payload @Valid EmojiRequest emojiRequest, - Principal principal + @Payload @Valid EmojiRequest emojiRequest ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = emojiRequest.memberId(); Bucket bucket = rateLimitService.getBucket(RateLimitType.EMOJI, memberId, emojiRequest.targetType(), emojiRequest.targetId()); @@ -47,10 +47,9 @@ public EmojiResponse createEmoji( @SendTo("/topic/rooms/{roomId}/emojis") public EmojiResponse deleteEmoji( @DestinationVariable Long roomId, - @Payload @Valid EmojiRequest emojiRequest, - Principal principal + @Payload @Valid EmojiRequest emojiRequest ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = emojiRequest.memberId(); Bucket bucket = rateLimitService.getBucket(RateLimitType.EMOJI, memberId, emojiRequest.targetType(), emojiRequest.targetId()); diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index c480843..6f0e73e 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -1,7 +1,5 @@ package com.oronaminc.join.websocket.api; -import java.security.Principal; - import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; @@ -37,11 +35,9 @@ public class QuestionWebsocketController { @SendTo("/topic/rooms/{roomId}/questions") public QuestionCreateResponse createQuestion( @DestinationVariable Long roomId, - @Payload @Valid QuestionRequest request, - Principal principal + @Payload @Valid QuestionRequest request ) { log.debug("수신한 메시지 = {}", request.content()); - log.debug("principal = {}", principal); Long memberId = request.memberId(); @@ -64,11 +60,10 @@ public QuestionCreateResponse createQuestion( public QuestionUpdateResponse updateQuestion( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - @Payload @Valid QuestionRequest request, - Principal principal + @Payload @Valid QuestionRequest request ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = request.memberId(); Question updated = questionService.update(memberId, roomId, questionId, request); @@ -80,9 +75,9 @@ public QuestionUpdateResponse updateQuestion( public QuestionDeleteResponse deleteQuestion( @DestinationVariable Long roomId, @DestinationVariable Long questionId, - Principal principal + @Payload @Valid StompMemberRequest request ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = request.memberId(); Long deletedId = questionService.delete(memberId, roomId, questionId); diff --git a/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java index a6221de..e4fe0e2 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/RoomWebsocketController.java @@ -1,7 +1,5 @@ package com.oronaminc.join.websocket.api; -import java.security.Principal; - import org.springframework.messaging.Message; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -25,10 +23,10 @@ public class RoomWebsocketController { @SendTo("/topic/rooms/{roomId}/join") public RoomJoinResponse joinRoom( @DestinationVariable Long roomId, - Principal principal, + StompMemberRequest request, Message message ) { - Long memberId = Long.valueOf(principal.getName()); + Long memberId = request.memberId(); StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); String sessionId = accessor.getSessionId(); diff --git a/src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java b/src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java new file mode 100644 index 0000000..a353e20 --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/api/StompMemberRequest.java @@ -0,0 +1,9 @@ +package com.oronaminc.join.websocket.api; + +import jakarta.validation.constraints.NotNull; + +public record StompMemberRequest( + @NotNull + Long memberId +) { +} diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index 9571356..126b86e 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -1,13 +1,24 @@ package com.oronaminc.join.answer.service; -import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_ROOM; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.assertj.core.api.InstanceOfAssertFactories.LIST; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; +import static com.oronaminc.join.global.exception.ErrorCode.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.InstanceOfAssertFactories.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Slice; +import org.springframework.test.util.ReflectionTestUtils; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; @@ -31,18 +42,6 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Slice; -import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class AnswerServiceTests { @@ -114,7 +113,7 @@ void setUp() { .participantType(ParticipantType.TEAM) .build(); - request = new AnswerRequest("답변입니다."); + request = new AnswerRequest("답변입니다.", mockMember.getId()); } @Test @@ -258,7 +257,7 @@ void updateAnswer_success() { given(permissionValidator.validateAnswerUpdatePermission(1L, 1L)) .willReturn(answer); - AnswerRequest request = new AnswerRequest("수정된 내용"); + AnswerRequest request = new AnswerRequest("수정된 내용", 1L); // when Answer result = answerService.update(answer.getId(), answer.getMember().getId(), request); diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java index b0c727e..5c6192f 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java @@ -89,7 +89,7 @@ void createEmoji_success_test() throws InterruptedException { executorService.submit(() -> { try { emojiFacade.createEmoji(members.get(idx).getId(), - new EmojiRequest(TargetType.ROOM, roomId)); + new EmojiRequest(TargetType.ROOM, roomId, members.get(idx).getId())); } catch (Exception e) { e.printStackTrace(); } finally { @@ -147,7 +147,7 @@ void deleteEmoji_success_test() throws InterruptedException { executorService.submit(() -> { try { emojiFacade.deleteEmoji(members.get(idx).getId(), - new EmojiRequest(TargetType.ROOM, roomId)); + new EmojiRequest(TargetType.ROOM, roomId, members.get(idx).getId())); } catch (Exception e) { e.printStackTrace(); } finally { diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java index e369c8d..c6e1ecb 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java @@ -1,9 +1,16 @@ package com.oronaminc.join.emoji.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; @@ -21,13 +28,6 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; import com.oronaminc.join.websocket.common.EventType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class EmojiServiceTests { @@ -81,7 +81,7 @@ void toggleEmoji_createRoomEmoji_success() { // when EmojiResponse response = emojiService.createEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.CREATE); @@ -119,7 +119,7 @@ void toggleEmoji_createQuestionEmoji_success() { // when EmojiResponse response = emojiService.createEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.CREATE); @@ -157,7 +157,7 @@ void toggleEmoji_createAnswerEmoji_success() { // when EmojiResponse response = emojiService.createEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.CREATE); @@ -194,7 +194,7 @@ void toggleEmoji_createRoomEmoji_fail() { // then assertThatThrownBy( () -> { - emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId)); + emojiService.createEmoji(memberId, new EmojiRequest(targetType, targetId, memberId)); } ).isInstanceOf(ErrorException.class); @@ -226,7 +226,7 @@ void toggleEmoji_deleteRoomEmoji_success() { // when EmojiResponse response = emojiService.deleteEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.DELETE); @@ -262,7 +262,7 @@ void toggleEmoji_deleteQuestionEmoji_success() { // when EmojiResponse response = emojiService.deleteEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.DELETE); @@ -298,7 +298,7 @@ void toggleEmoji_deleteAnswerEmoji_success() { // when EmojiResponse response = emojiService.deleteEmoji(memberId, - new EmojiRequest(targetType, targetId)); + new EmojiRequest(targetType, targetId, memberId)); // then assertThat(response.event()).isEqualTo(EventType.DELETE); @@ -335,7 +335,7 @@ void toggleEmoji_deleteRoomEmoji_fail() { // then assertThatThrownBy( () -> { - emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId)); + emojiService.deleteEmoji(memberId, new EmojiRequest(targetType, targetId, memberId)); } ).isInstanceOf(ErrorException.class); diff --git a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java index d37c894..f8c30d4 100644 --- a/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java +++ b/src/test/java/com/oronaminc/join/question/service/QuestionServiceTests.java @@ -1,13 +1,23 @@ package com.oronaminc.join.question.service; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.doNothing; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.verify; -import static org.mockito.BDDMockito.willThrow; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorCode; @@ -26,20 +36,8 @@ import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; -import java.time.LocalDateTime; -import java.util.List; + import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.test.context.ActiveProfiles; @Slf4j @ActiveProfiles("test") @@ -93,7 +91,7 @@ void setUp() { .roomStatus(RoomStatus.STARTED) .build(); - request = new QuestionRequest("질문입니다"); + request = new QuestionRequest("질문입니다", mockMember.getId()); mockQ1 = QuestionFlatResponse.builder() .questionId(1L) From 87963ab6c86b3e13fc45cb3e0619f83531dad335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 18:18:46 +0900 Subject: [PATCH 57/74] =?UTF-8?q?fix:=20=EB=8B=B5=EB=B3=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 --- .../join/answer/service/AnswerReader.java | 18 ++++++++---------- .../join/answer/service/AnswerService.java | 19 ++++++++++--------- .../answer/service/AnswerServiceTests.java | 4 ---- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java index 773a534..349c842 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java @@ -1,16 +1,19 @@ package com.oronaminc.join.answer.service; -import com.oronaminc.join.answer.dao.AnswerRepository; -import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.global.exception.ErrorException; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import lombok.RequiredArgsConstructor; + import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import com.oronaminc.join.answer.dao.AnswerRepository; +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; + +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class AnswerReader { @@ -31,11 +34,6 @@ public List getAnswerByQuestionIdWithCursor(Long questionId, pageable); } - public Answer getByQuestionId(Long questionId) { - return answerRepository.findByQuestionId(questionId) - .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_EXIST_ANSWER)); - } - public Answer getById(Long answerId) { return findById(answerId) .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_ANSWER)); diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index cc06dff..699123d 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -1,5 +1,14 @@ package com.oronaminc.join.answer.service; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; @@ -16,15 +25,8 @@ import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; + import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -65,7 +67,6 @@ public Slice getAnswers( memberReader.getById(memberId); roomReader.getById(roomId); questionReader.getByIdAndRoomId(questionId, roomId); - answerReader.getByQuestionId(questionId); Pageable pageable = PageRequest.of(0, size + 1); diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index 126b86e..d59c318 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -152,7 +152,6 @@ void getAnswers_firstPage_success() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, List.of(100L, 99L))) @@ -174,7 +173,6 @@ void getAnswers_cursorPaging_success() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getAnswerByQuestionIdWithCursor(eq(1L), any(), any(), any())).willReturn( answers); given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, @@ -198,7 +196,6 @@ void getAnswers_containsEmojiedAnswers() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, List.of(1L, 2L))) @@ -220,7 +217,6 @@ void getAnswers_emptyList_noError() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(List.of()); // when From 0aa089c14e75a007d972231514ae3b0b73b672f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 18:19:29 +0900 Subject: [PATCH 58/74] =?UTF-8?q?release:=20=EB=8B=B5=EB=B3=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95=20=EC=BD=94=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 * fix: 답변 기능 수정 (#128) * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/answer/service/AnswerReader.java | 18 ++++++++---------- .../join/answer/service/AnswerService.java | 19 ++++++++++--------- .../answer/service/AnswerServiceTests.java | 4 ---- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java index 773a534..349c842 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java @@ -1,16 +1,19 @@ package com.oronaminc.join.answer.service; -import com.oronaminc.join.answer.dao.AnswerRepository; -import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.global.exception.ErrorException; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import lombok.RequiredArgsConstructor; + import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import com.oronaminc.join.answer.dao.AnswerRepository; +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.global.exception.ErrorCode; +import com.oronaminc.join.global.exception.ErrorException; + +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class AnswerReader { @@ -31,11 +34,6 @@ public List getAnswerByQuestionIdWithCursor(Long questionId, pageable); } - public Answer getByQuestionId(Long questionId) { - return answerRepository.findByQuestionId(questionId) - .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_EXIST_ANSWER)); - } - public Answer getById(Long answerId) { return findById(answerId) .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_ANSWER)); diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index cc06dff..699123d 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -1,5 +1,14 @@ package com.oronaminc.join.answer.service; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; @@ -16,15 +25,8 @@ import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; + import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @@ -65,7 +67,6 @@ public Slice getAnswers( memberReader.getById(memberId); roomReader.getById(roomId); questionReader.getByIdAndRoomId(questionId, roomId); - answerReader.getByQuestionId(questionId); Pageable pageable = PageRequest.of(0, size + 1); diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index 126b86e..d59c318 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -152,7 +152,6 @@ void getAnswers_firstPage_success() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, List.of(100L, 99L))) @@ -174,7 +173,6 @@ void getAnswers_cursorPaging_success() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getAnswerByQuestionIdWithCursor(eq(1L), any(), any(), any())).willReturn( answers); given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, @@ -198,7 +196,6 @@ void getAnswers_containsEmojiedAnswers() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, List.of(1L, 2L))) @@ -220,7 +217,6 @@ void getAnswers_emptyList_noError() { given(memberReader.getById(1L)).willReturn(mockMember); given(roomReader.getById(1L)).willReturn(mockRoom); given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(1L)).willReturn(null); given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(List.of()); // when From 09354a6d4e67bb2ccfe2875352bd87e18215c331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:17:37 +0900 Subject: [PATCH 59/74] =?UTF-8?q?fix:=20cors=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 --- .../java/com/oronaminc/join/global/exception/ErrorCode.java | 2 +- .../java/com/oronaminc/join/member/security/SecurityConfig.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 865658b..e710928 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -29,7 +29,7 @@ public enum ErrorCode { UNAUTHORIZED_UPDATE_AND_DELETE("PARTICIPANT-003", "발표방 수정 및 삭제 권한이 없습니다.", UNAUTHORIZED), UNAUTHORIZED_REPORT_READ("PARTICIPANT-004", "결과 리포트 조회 권한이 없습니다.", UNAUTHORIZED), UNAUTHORIZED_LIMIT_PARTICIPANT("PARTICIPANT-005", "인원이 가득 차 참가할 수 없습니다.", UNAUTHORIZED), - UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-005", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), + UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-006", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 8332001..572c7ee 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -62,7 +62,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); configuration.setAllowedOriginPatterns(List.of("*")); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); From e67355976fd026ce614613c4a7fba1f8cc6bcfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:18:36 +0900 Subject: [PATCH 60/74] =?UTF-8?q?release:=20=EC=BD=94=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 * fix: 답변 기능 수정 (#128) * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 * fix: cors 메서드 수정 (#131) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../java/com/oronaminc/join/global/exception/ErrorCode.java | 2 +- .../java/com/oronaminc/join/member/security/SecurityConfig.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 865658b..e710928 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -29,7 +29,7 @@ public enum ErrorCode { UNAUTHORIZED_UPDATE_AND_DELETE("PARTICIPANT-003", "발표방 수정 및 삭제 권한이 없습니다.", UNAUTHORIZED), UNAUTHORIZED_REPORT_READ("PARTICIPANT-004", "결과 리포트 조회 권한이 없습니다.", UNAUTHORIZED), UNAUTHORIZED_LIMIT_PARTICIPANT("PARTICIPANT-005", "인원이 가득 차 참가할 수 없습니다.", UNAUTHORIZED), - UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-005", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), + UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-006", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 8332001..572c7ee 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -62,7 +62,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); configuration.setAllowedOriginPatterns(List.of("*")); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); From fb221ebb6903b818132dcf0e2e02565ef32c4986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:35:08 +0900 Subject: [PATCH 61/74] =?UTF-8?q?fix:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 --- .../join/member/security/SecurityConfig.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 572c7ee..eb409e0 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -30,12 +30,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/auth/guest", - "/api/auth/kakao", - "/login" - ) - .anonymous() + // .requestMatchers( + // "/api/auth/guest", + // "/api/auth/kakao", + // "/login" + // ) + // .anonymous() .requestMatchers( "/swagger-ui/**", "/swagger-resources/**", @@ -47,8 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/ws/**" ) .permitAll() - .requestMatchers("/ws/**").permitAll() - .anyRequest().authenticated() + .anyRequest() + .authenticated() ) .formLogin(AbstractHttpConfigurer::disable) .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo From 349499d52cae0c3ad46dbd6102c01ad908adfa9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:35:50 +0900 Subject: [PATCH 62/74] =?UTF-8?q?release:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95=20=EC=BD=94=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 * fix: 답변 기능 수정 (#128) * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 * fix: cors 메서드 수정 (#131) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 시큐리티 수정 (#133) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/member/security/SecurityConfig.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 572c7ee..eb409e0 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -30,12 +30,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/auth/guest", - "/api/auth/kakao", - "/login" - ) - .anonymous() + // .requestMatchers( + // "/api/auth/guest", + // "/api/auth/kakao", + // "/login" + // ) + // .anonymous() .requestMatchers( "/swagger-ui/**", "/swagger-resources/**", @@ -47,8 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/ws/**" ) .permitAll() - .requestMatchers("/ws/**").permitAll() - .anyRequest().authenticated() + .anyRequest() + .authenticated() ) .formLogin(AbstractHttpConfigurer::disable) .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo From c13703f139829b1db864c0d46e2cdfdc4948784e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:42:05 +0900 Subject: [PATCH 63/74] =?UTF-8?q?fix:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20permitAll=20=EC=B6=94=EA=B0=80=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 --- .../com/oronaminc/join/member/security/SecurityConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index eb409e0..87f65f1 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -44,7 +44,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/login/oauth2/code/kakao", "/api/auth/logout", "/dev/**", - "/ws/**" + "/ws/**", + "/api/auth/guest", + "/api/auth/kakao", + "/login" ) .permitAll() .anyRequest() From 2616ce8940f25400db9b50300cc81d8fe962bf7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:42:41 +0900 Subject: [PATCH 64/74] =?UTF-8?q?release:=20=EC=BD=94=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 * fix: 답변 기능 수정 (#128) * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 * fix: cors 메서드 수정 (#131) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 시큐리티 수정 (#133) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 permitAll 추가 (#135) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../com/oronaminc/join/member/security/SecurityConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index eb409e0..87f65f1 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -44,7 +44,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/login/oauth2/code/kakao", "/api/auth/logout", "/dev/**", - "/ws/**" + "/ws/**", + "/api/auth/guest", + "/api/auth/kakao", + "/login" ) .permitAll() .anyRequest() From f8152287ccd0a3b5fff7f99c758c8233e300dd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=EA=B2=BD=EC=A4=80?= Date: Thu, 31 Jul 2025 11:28:45 +0900 Subject: [PATCH 65/74] =?UTF-8?q?fix:=20=EB=8B=B5=EB=B3=80=EC=9C=A8=20?= =?UTF-8?q?=ED=8D=BC=EC=84=BC=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/answer/dao/AnswerRepository.java | 61 ++++++++++--------- .../join/room/service/RoomService.java | 45 ++++++-------- 2 files changed, 49 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index d06e03a..dcaf09d 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -2,43 +2,44 @@ import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.question.domain.Question; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + public interface AnswerRepository extends JpaRepository { Optional findByQuestionId(Long questionId); @Query(""" - SELECT a - FROM Answer a - JOIN FETCH a.member m - WHERE a.question.id = :questionId - ORDER BY a.createdAt DESC, a.id DESC - """) + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + ORDER BY a.createdAt ASC, a.id ASC + """) List findFirstPageByQuestionId( - @Param("questionId") Long questionId, - Pageable pageable + @Param("questionId") Long questionId, + Pageable pageable ); @Query(""" - SELECT a - FROM Answer a - JOIN FETCH a.member m - WHERE a.question.id = :questionId - AND (a.createdAt < :lastCreatedAt OR (a.createdAt = :lastCreatedAt AND a.id < :lastId)) - ORDER BY a.createdAt DESC, a.id DESC - """) + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + AND (a.createdAt > :lastCreatedAt OR (a.createdAt = :lastCreatedAt AND a.id > :lastId)) + ORDER BY a.createdAt ASC, a.id ASC + """) List findByQuestionIdWithCursor( - @Param("questionId") Long questionId, - @Param("lastCreatedAt") LocalDateTime lastCreatedAt, - @Param("lastId") Long lastId, - Pageable pageable + @Param("questionId") Long questionId, + @Param("lastCreatedAt") LocalDateTime lastCreatedAt, + @Param("lastId") Long lastId, + Pageable pageable ); void deleteByQuestionId(Long questionId); @@ -46,17 +47,17 @@ List findByQuestionIdWithCursor( void deleteByQuestionIn(List questions); @Query(""" - select count(distinct a.question.id) - from Answer a - where a.question.room.id = :roomId - """) + select count(distinct a.question.id) + from Answer a + where a.question.room.id = :roomId + """) Long countAnsweredQuestionsByRoomId(@Param("roomId") Long roomId); @Query(""" - select a - from Answer a - where a.question.id in :questionIds - """) + select a + from Answer a + where a.question.id in :questionIds + """) List findAllByQuestionIds(@Param("questionIds") List questionIds); } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 9f04704..cbf3cbe 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -1,16 +1,5 @@ package com.oronaminc.join.room.service; -import static com.oronaminc.join.global.exception.ErrorCode.*; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.document.domain.Document; @@ -27,23 +16,22 @@ import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.dto.CreateRoomRequest; -import com.oronaminc.join.room.dto.CreateRoomResponse; -import com.oronaminc.join.room.dto.JoinRoomRequest; -import com.oronaminc.join.room.dto.JoinRoomResponse; -import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.dto.*; import com.oronaminc.join.room.event.RoomDeleteEvent; -import com.oronaminc.join.room.dto.RoomDetailResponse; -import com.oronaminc.join.room.dto.RoomJoinResponse; -import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; -import com.oronaminc.join.room.dto.RoomUpdateRequest; -import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; -import com.oronaminc.join.room.dto.TopQnAResponse; import com.oronaminc.join.room.util.CodeGenerator; import com.oronaminc.join.room.util.RoomMapper; import com.oronaminc.join.websocket.session.CurrentParticipantManager; - import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.oronaminc.join.global.exception.ErrorCode.*; @Service @Transactional(readOnly = true) @@ -139,7 +127,7 @@ public void deleteRoom(Long memberId, Long roomId) { @Transactional @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoomStatus(Long memberId, Long roomId, - RoomUpdateStatusRequest roomUpdateStatusRequest) { + RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); @@ -215,9 +203,12 @@ private List getTopQnA(Long roomId) { } private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuestion) { - return (totalQuestions == 0) - ? 0.0 - : ((double)totalAnswerByQuestion / totalQuestions) * 100; + if (totalQuestions == 0) { + return 0.0; + } + + double rate = ((double) totalAnswerByQuestion / totalQuestions) * 100; + return Math.round(rate * 10.0) / 10.0; } From ac354df4da0a09938ed647454e2a942c5051b6b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=EA=B2=BD=EC=A4=80?= Date: Thu, 31 Jul 2025 11:30:50 +0900 Subject: [PATCH 66/74] =?UTF-8?q?release:=20=EC=BD=94=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 * fix: 답변 기능 수정 (#128) * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 * fix: cors 메서드 수정 (#131) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 시큐리티 수정 (#133) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 permitAll 추가 (#135) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 * fix: 답변율 퍼센트 수정 (#138) --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: 김건우 <96411818+rjswjddn@users.noreply.github.com> Co-authored-by: 김건우 Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/answer/dao/AnswerRepository.java | 61 ++++++++++--------- .../join/room/service/RoomService.java | 45 ++++++-------- 2 files changed, 49 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index d06e03a..dcaf09d 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -2,43 +2,44 @@ import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.question.domain.Question; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + public interface AnswerRepository extends JpaRepository { Optional findByQuestionId(Long questionId); @Query(""" - SELECT a - FROM Answer a - JOIN FETCH a.member m - WHERE a.question.id = :questionId - ORDER BY a.createdAt DESC, a.id DESC - """) + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + ORDER BY a.createdAt ASC, a.id ASC + """) List findFirstPageByQuestionId( - @Param("questionId") Long questionId, - Pageable pageable + @Param("questionId") Long questionId, + Pageable pageable ); @Query(""" - SELECT a - FROM Answer a - JOIN FETCH a.member m - WHERE a.question.id = :questionId - AND (a.createdAt < :lastCreatedAt OR (a.createdAt = :lastCreatedAt AND a.id < :lastId)) - ORDER BY a.createdAt DESC, a.id DESC - """) + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + AND (a.createdAt > :lastCreatedAt OR (a.createdAt = :lastCreatedAt AND a.id > :lastId)) + ORDER BY a.createdAt ASC, a.id ASC + """) List findByQuestionIdWithCursor( - @Param("questionId") Long questionId, - @Param("lastCreatedAt") LocalDateTime lastCreatedAt, - @Param("lastId") Long lastId, - Pageable pageable + @Param("questionId") Long questionId, + @Param("lastCreatedAt") LocalDateTime lastCreatedAt, + @Param("lastId") Long lastId, + Pageable pageable ); void deleteByQuestionId(Long questionId); @@ -46,17 +47,17 @@ List findByQuestionIdWithCursor( void deleteByQuestionIn(List questions); @Query(""" - select count(distinct a.question.id) - from Answer a - where a.question.room.id = :roomId - """) + select count(distinct a.question.id) + from Answer a + where a.question.room.id = :roomId + """) Long countAnsweredQuestionsByRoomId(@Param("roomId") Long roomId); @Query(""" - select a - from Answer a - where a.question.id in :questionIds - """) + select a + from Answer a + where a.question.id in :questionIds + """) List findAllByQuestionIds(@Param("questionIds") List questionIds); } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 9f04704..cbf3cbe 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -1,16 +1,5 @@ package com.oronaminc.join.room.service; -import static com.oronaminc.join.global.exception.ErrorCode.*; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.document.domain.Document; @@ -27,23 +16,22 @@ import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.dto.CreateRoomRequest; -import com.oronaminc.join.room.dto.CreateRoomResponse; -import com.oronaminc.join.room.dto.JoinRoomRequest; -import com.oronaminc.join.room.dto.JoinRoomResponse; -import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.dto.*; import com.oronaminc.join.room.event.RoomDeleteEvent; -import com.oronaminc.join.room.dto.RoomDetailResponse; -import com.oronaminc.join.room.dto.RoomJoinResponse; -import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; -import com.oronaminc.join.room.dto.RoomUpdateRequest; -import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; -import com.oronaminc.join.room.dto.TopQnAResponse; import com.oronaminc.join.room.util.CodeGenerator; import com.oronaminc.join.room.util.RoomMapper; import com.oronaminc.join.websocket.session.CurrentParticipantManager; - import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.oronaminc.join.global.exception.ErrorCode.*; @Service @Transactional(readOnly = true) @@ -139,7 +127,7 @@ public void deleteRoom(Long memberId, Long roomId) { @Transactional @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoomStatus(Long memberId, Long roomId, - RoomUpdateStatusRequest roomUpdateStatusRequest) { + RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); @@ -215,9 +203,12 @@ private List getTopQnA(Long roomId) { } private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuestion) { - return (totalQuestions == 0) - ? 0.0 - : ((double)totalAnswerByQuestion / totalQuestions) * 100; + if (totalQuestions == 0) { + return 0.0; + } + + double rate = ((double) totalAnswerByQuestion / totalQuestions) * 100; + return Math.round(rate * 10.0) / 10.0; } From 5e5d1155c4ab03ba972d156f52612d2a504d4d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=EA=B2=BD=EC=A4=80?= Date: Thu, 31 Jul 2025 15:26:13 +0900 Subject: [PATCH 67/74] =?UTF-8?q?fix:=20=EC=B0=B8=EC=97=AC=ED=95=9C=20?= =?UTF-8?q?=EB=B0=9C=ED=91=9C=EB=B0=A9=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=BC,=20=EC=B0=B8=EC=97=AC=EC=9D=BC?= =?UTF-8?q?=EC=9D=B4=20=EB=82=98=EC=98=A4=EA=B2=8C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 참여한 발표방 조회 시 생성일, 참여일이 나오게 변경 * test: 변경에 따른 테스트 코드 수정 --- .../join/member/util/MyPageMapper.java | 39 +++++---- .../member/service/MyPageServiceTests.java | 80 +++++++++---------- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java b/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java index ccdb1eb..bf1c3dd 100644 --- a/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java @@ -4,35 +4,44 @@ import com.oronaminc.join.member.dto.MyRoomsGetResponse; import com.oronaminc.join.member.dto.ParticipationType; import com.oronaminc.join.participant.domain.Participant; +import com.oronaminc.join.participant.domain.ParticipantType; import com.oronaminc.join.room.domain.Room; -import java.util.Map; import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.springframework.data.domain.Page; +import java.time.LocalDate; +import java.util.Map; + @NoArgsConstructor(access = AccessLevel.PRIVATE) public class MyPageMapper { public static MyRoomsGetResponse toMyRoomsGetResponse(Page response) { return MyRoomsGetResponse.builder() - .content(response.getContent()) - .currentPage(response.getNumber()) - .size(response.getSize()) - .totalElements(response.getTotalElements()) - .totalPages(response.getTotalPages()) - .build(); + .content(response.getContent()) + .currentPage(response.getNumber()) + .size(response.getSize()) + .totalElements(response.getTotalElements()) + .totalPages(response.getTotalPages()) + .build(); } public static MyRoomsDto toMyRoomsDto(Participant p, Map countMap) { Room room = p.getRoom(); + LocalDate date; + if (p.getParticipantType() == ParticipantType.PRESENTER) { + date = room.getCreatedAt().toLocalDate(); + } else { + date = p.getCreatedAt().toLocalDate(); + } return MyRoomsDto.builder() - .roomId(room.getId()) - .title(room.getTitle()) - .emojiCount(room.getEmojiCount()) - .status(room.getRoomStatus()) - .startedAt(room.getCreatedAt().toLocalDate()) - .participationType(ParticipationType.from(p.getParticipantType())) - .questions(countMap.getOrDefault(room.getId(), 0L)) - .build(); + .roomId(room.getId()) + .title(room.getTitle()) + .emojiCount(room.getEmojiCount()) + .status(room.getRoomStatus()) + .startedAt(date) + .participationType(ParticipationType.from(p.getParticipantType())) + .questions(countMap.getOrDefault(room.getId(), 0L)) + .build(); } } diff --git a/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java b/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java index 7e14314..8e65792 100644 --- a/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java +++ b/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java @@ -1,11 +1,12 @@ package com.oronaminc.join.member.service; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.time.LocalDateTime; -import java.util.List; - +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.member.dto.*; +import com.oronaminc.join.participant.domain.Participant; +import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.participant.service.ParticipantReader; +import com.oronaminc.join.question.service.QuestionReader; +import com.oronaminc.join.room.domain.Room; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -18,18 +19,11 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.dto.MyPageType; -import com.oronaminc.join.member.dto.MyProfileGetResponse; -import com.oronaminc.join.member.dto.MyProfileUpdateRequest; -import com.oronaminc.join.member.dto.MyRoomsGetResponse; -import com.oronaminc.join.member.dto.ParticipantCountDto; -import com.oronaminc.join.member.dto.ParticipationType; -import com.oronaminc.join.participant.domain.Participant; -import com.oronaminc.join.participant.domain.ParticipantType; -import com.oronaminc.join.participant.service.ParticipantReader; -import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.room.domain.Room; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class MyPageServiceTests { @@ -54,14 +48,14 @@ void getMyProfile_success_test() { Member member = Member.builder().build(); List pc = List.of( - new ParticipantCountDto(ParticipantType.PRESENTER, 1L), - new ParticipantCountDto(ParticipantType.TEAM, 1L), - new ParticipantCountDto(ParticipantType.GUEST, 1L) + new ParticipantCountDto(ParticipantType.PRESENTER, 1L), + new ParticipantCountDto(ParticipantType.TEAM, 1L), + new ParticipantCountDto(ParticipantType.GUEST, 1L) ); when(memberReader.getById(member.getId())).thenReturn(member); when(participantReader.countByMemberIdGroupByParticipantType(member.getId())) - .thenReturn(pc); + .thenReturn(pc); // when MyProfileGetResponse myProfile = myPageService.getMyProfile(member.getId()); @@ -84,7 +78,7 @@ void getMyProfile_success_test2() { when(memberReader.getById(member.getId())).thenReturn(member); when(participantReader.countByMemberIdGroupByParticipantType(member.getId())) - .thenReturn(pc); + .thenReturn(pc); // when MyProfileGetResponse myProfile = myPageService.getMyProfile(member.getId()); @@ -127,8 +121,8 @@ void getMyRooms_success_test() { Pageable pageable = PageRequest.of(0, 10); Room room1 = Room.builder() - .title("~1~의 정석") - .build(); + .title("~1~의 정석") + .build(); Room room2 = Room.builder().title("~2~의 정석").build(); Room room3 = Room.builder().title("~3~의 정석").build(); ReflectionTestUtils.setField(room1, "id", 100L); @@ -139,33 +133,35 @@ void getMyRooms_success_test() { ReflectionTestUtils.setField(room3, "createdAt", LocalDateTime.now()); Participant participant1 = Participant.builder() - .room(room1) - .member(member) - .participantType(ParticipantType.PRESENTER) - .build(); + .room(room1) + .member(member) + .participantType(ParticipantType.PRESENTER) + .build(); Participant participant2 = Participant.builder() - .room(room2) - .member(member) - .participantType(ParticipantType.TEAM) - .build(); + .room(room2) + .member(member) + .participantType(ParticipantType.TEAM) + .build(); Participant participant3 = Participant.builder() - .room(room3) - .member(member) - .participantType(ParticipantType.GUEST) - .build(); + .room(room3) + .member(member) + .participantType(ParticipantType.GUEST) + .build(); + ReflectionTestUtils.setField(participant2, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(participant3, "createdAt", LocalDateTime.now()); List pc = List.of(participant1, participant2, participant3); Page participantPage = new PageImpl<>(pc, pageable, 1); List roomIds = List.of(room1.getId(), room2.getId(), room3.getId()); List questions = List.of( - new Object[]{room1.getId(), 1L}, - new Object[]{room2.getId(), 2L}, - new Object[]{room3.getId(), 3L} + new Object[]{room1.getId(), 1L}, + new Object[]{room2.getId(), 2L}, + new Object[]{room3.getId(), 3L} ); when(participantReader.findByMemberId(memberId, pageable)) - .thenReturn(participantPage); + .thenReturn(participantPage); when(questionReader.countByRoomIds(roomIds)).thenReturn(questions); // when @@ -176,7 +172,7 @@ void getMyRooms_success_test() { assertThat(result.content().getFirst().roomId()).isEqualTo(100L); assertThat(result.content().getFirst().title()).isEqualTo("~1~의 정석"); assertThat(result.content().getFirst().participationType()).isEqualTo( - ParticipationType.CREATED); + ParticipationType.CREATED); assertThat(result.content().get(1).participationType()).isEqualTo(ParticipationType.JOINED); } From 4a6e59d03f9c300b145241d40e3357c60a723e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=EA=B2=BD=EC=A4=80?= Date: Thu, 31 Jul 2025 15:27:38 +0900 Subject: [PATCH 68/74] =?UTF-8?q?release:=20=EC=BD=94=EB=93=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 * fix: 답변 기능 수정 (#128) * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 * fix: cors 메서드 수정 (#131) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 시큐리티 수정 (#133) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 permitAll 추가 (#135) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 * fix: 답변율 퍼센트 수정 (#138) * fix: 참여한 발표방 조회 시 생성일, 참여일이 나오게 변경 (#141) * fix: 참여한 발표방 조회 시 생성일, 참여일이 나오게 변경 * test: 변경에 따른 테스트 코드 수정 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: 김건우 <96411818+rjswjddn@users.noreply.github.com> Co-authored-by: 김건우 Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> --- .../join/member/util/MyPageMapper.java | 39 +++++---- .../member/service/MyPageServiceTests.java | 80 +++++++++---------- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java b/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java index ccdb1eb..bf1c3dd 100644 --- a/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MyPageMapper.java @@ -4,35 +4,44 @@ import com.oronaminc.join.member.dto.MyRoomsGetResponse; import com.oronaminc.join.member.dto.ParticipationType; import com.oronaminc.join.participant.domain.Participant; +import com.oronaminc.join.participant.domain.ParticipantType; import com.oronaminc.join.room.domain.Room; -import java.util.Map; import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.springframework.data.domain.Page; +import java.time.LocalDate; +import java.util.Map; + @NoArgsConstructor(access = AccessLevel.PRIVATE) public class MyPageMapper { public static MyRoomsGetResponse toMyRoomsGetResponse(Page response) { return MyRoomsGetResponse.builder() - .content(response.getContent()) - .currentPage(response.getNumber()) - .size(response.getSize()) - .totalElements(response.getTotalElements()) - .totalPages(response.getTotalPages()) - .build(); + .content(response.getContent()) + .currentPage(response.getNumber()) + .size(response.getSize()) + .totalElements(response.getTotalElements()) + .totalPages(response.getTotalPages()) + .build(); } public static MyRoomsDto toMyRoomsDto(Participant p, Map countMap) { Room room = p.getRoom(); + LocalDate date; + if (p.getParticipantType() == ParticipantType.PRESENTER) { + date = room.getCreatedAt().toLocalDate(); + } else { + date = p.getCreatedAt().toLocalDate(); + } return MyRoomsDto.builder() - .roomId(room.getId()) - .title(room.getTitle()) - .emojiCount(room.getEmojiCount()) - .status(room.getRoomStatus()) - .startedAt(room.getCreatedAt().toLocalDate()) - .participationType(ParticipationType.from(p.getParticipantType())) - .questions(countMap.getOrDefault(room.getId(), 0L)) - .build(); + .roomId(room.getId()) + .title(room.getTitle()) + .emojiCount(room.getEmojiCount()) + .status(room.getRoomStatus()) + .startedAt(date) + .participationType(ParticipationType.from(p.getParticipantType())) + .questions(countMap.getOrDefault(room.getId(), 0L)) + .build(); } } diff --git a/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java b/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java index 7e14314..8e65792 100644 --- a/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java +++ b/src/test/java/com/oronaminc/join/member/service/MyPageServiceTests.java @@ -1,11 +1,12 @@ package com.oronaminc.join.member.service; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.time.LocalDateTime; -import java.util.List; - +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.member.dto.*; +import com.oronaminc.join.participant.domain.Participant; +import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.participant.service.ParticipantReader; +import com.oronaminc.join.question.service.QuestionReader; +import com.oronaminc.join.room.domain.Room; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -18,18 +19,11 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.dto.MyPageType; -import com.oronaminc.join.member.dto.MyProfileGetResponse; -import com.oronaminc.join.member.dto.MyProfileUpdateRequest; -import com.oronaminc.join.member.dto.MyRoomsGetResponse; -import com.oronaminc.join.member.dto.ParticipantCountDto; -import com.oronaminc.join.member.dto.ParticipationType; -import com.oronaminc.join.participant.domain.Participant; -import com.oronaminc.join.participant.domain.ParticipantType; -import com.oronaminc.join.participant.service.ParticipantReader; -import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.room.domain.Room; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class MyPageServiceTests { @@ -54,14 +48,14 @@ void getMyProfile_success_test() { Member member = Member.builder().build(); List pc = List.of( - new ParticipantCountDto(ParticipantType.PRESENTER, 1L), - new ParticipantCountDto(ParticipantType.TEAM, 1L), - new ParticipantCountDto(ParticipantType.GUEST, 1L) + new ParticipantCountDto(ParticipantType.PRESENTER, 1L), + new ParticipantCountDto(ParticipantType.TEAM, 1L), + new ParticipantCountDto(ParticipantType.GUEST, 1L) ); when(memberReader.getById(member.getId())).thenReturn(member); when(participantReader.countByMemberIdGroupByParticipantType(member.getId())) - .thenReturn(pc); + .thenReturn(pc); // when MyProfileGetResponse myProfile = myPageService.getMyProfile(member.getId()); @@ -84,7 +78,7 @@ void getMyProfile_success_test2() { when(memberReader.getById(member.getId())).thenReturn(member); when(participantReader.countByMemberIdGroupByParticipantType(member.getId())) - .thenReturn(pc); + .thenReturn(pc); // when MyProfileGetResponse myProfile = myPageService.getMyProfile(member.getId()); @@ -127,8 +121,8 @@ void getMyRooms_success_test() { Pageable pageable = PageRequest.of(0, 10); Room room1 = Room.builder() - .title("~1~의 정석") - .build(); + .title("~1~의 정석") + .build(); Room room2 = Room.builder().title("~2~의 정석").build(); Room room3 = Room.builder().title("~3~의 정석").build(); ReflectionTestUtils.setField(room1, "id", 100L); @@ -139,33 +133,35 @@ void getMyRooms_success_test() { ReflectionTestUtils.setField(room3, "createdAt", LocalDateTime.now()); Participant participant1 = Participant.builder() - .room(room1) - .member(member) - .participantType(ParticipantType.PRESENTER) - .build(); + .room(room1) + .member(member) + .participantType(ParticipantType.PRESENTER) + .build(); Participant participant2 = Participant.builder() - .room(room2) - .member(member) - .participantType(ParticipantType.TEAM) - .build(); + .room(room2) + .member(member) + .participantType(ParticipantType.TEAM) + .build(); Participant participant3 = Participant.builder() - .room(room3) - .member(member) - .participantType(ParticipantType.GUEST) - .build(); + .room(room3) + .member(member) + .participantType(ParticipantType.GUEST) + .build(); + ReflectionTestUtils.setField(participant2, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(participant3, "createdAt", LocalDateTime.now()); List pc = List.of(participant1, participant2, participant3); Page participantPage = new PageImpl<>(pc, pageable, 1); List roomIds = List.of(room1.getId(), room2.getId(), room3.getId()); List questions = List.of( - new Object[]{room1.getId(), 1L}, - new Object[]{room2.getId(), 2L}, - new Object[]{room3.getId(), 3L} + new Object[]{room1.getId(), 1L}, + new Object[]{room2.getId(), 2L}, + new Object[]{room3.getId(), 3L} ); when(participantReader.findByMemberId(memberId, pageable)) - .thenReturn(participantPage); + .thenReturn(participantPage); when(questionReader.countByRoomIds(roomIds)).thenReturn(questions); // when @@ -176,7 +172,7 @@ void getMyRooms_success_test() { assertThat(result.content().getFirst().roomId()).isEqualTo(100L); assertThat(result.content().getFirst().title()).isEqualTo("~1~의 정석"); assertThat(result.content().getFirst().participationType()).isEqualTo( - ParticipationType.CREATED); + ParticipationType.CREATED); assertThat(result.content().get(1).participationType()).isEqualTo(ParticipationType.JOINED); } From 35559878affde2767d677a3727ccbc4624553aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:28:08 +0900 Subject: [PATCH 69/74] =?UTF-8?q?fix:=20=EC=BA=90=EC=8B=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 * fix: 캐시 삭제 추가 --- .../join/room/service/RoomService.java | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index cbf3cbe..c06ea32 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -1,5 +1,17 @@ package com.oronaminc.join.room.service; +import static com.oronaminc.join.global.exception.ErrorCode.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.document.domain.Document; @@ -16,22 +28,23 @@ import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.dto.*; +import com.oronaminc.join.room.dto.CreateRoomRequest; +import com.oronaminc.join.room.dto.CreateRoomResponse; +import com.oronaminc.join.room.dto.JoinRoomRequest; +import com.oronaminc.join.room.dto.JoinRoomResponse; +import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.dto.RoomDetailResponse; +import com.oronaminc.join.room.dto.RoomJoinResponse; +import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; +import com.oronaminc.join.room.dto.RoomUpdateRequest; +import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; +import com.oronaminc.join.room.dto.TopQnAResponse; import com.oronaminc.join.room.event.RoomDeleteEvent; import com.oronaminc.join.room.util.CodeGenerator; import com.oronaminc.join.room.util.RoomMapper; import com.oronaminc.join.websocket.session.CurrentParticipantManager; -import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static com.oronaminc.join.global.exception.ErrorCode.*; +import lombok.RequiredArgsConstructor; @Service @Transactional(readOnly = true) @@ -49,6 +62,7 @@ public class RoomService { private final RoomReader roomReader; private final CurrentParticipantManager currentParticipantManager; private final ApplicationEventPublisher publisher; + private final CacheManager cacheManager; private static final int CODE_LENGTH = 6; @@ -92,7 +106,6 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { } @Transactional - @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomRequest) { participantService.validatePresenter(roomId, memberId); @@ -105,10 +118,11 @@ public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomR room.update(updateRoomRequest); participantService.updateTeam(room, updateRoomRequest.teamEmail()); documentService.updateDocument(updateRoomRequest.documentUrl(), roomId); + + clearCache(room.getId(), room.getSecretCode()); } @Transactional - @CacheEvict(cacheNames = "roomById", key = "#roomId") public void deleteRoom(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); @@ -119,18 +133,19 @@ public void deleteRoom(Long memberId, Long roomId) { throw new ErrorException(BAD_REQUEST_ROOM_STARTED); } + clearCache(room.getId(), room.getSecretCode()); publisher.publishEvent(new RoomDeleteEvent(roomId)); roomRepository.deleteById(roomId); s3Service.deleteFile(document.getFileUrl()); } @Transactional - @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoomStatus(Long memberId, Long roomId, - RoomUpdateStatusRequest roomUpdateStatusRequest) { + RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); + clearCache(room.getId(), room.getSecretCode()); RoomStatus updateStatus = roomUpdateStatusRequest.roomStatus(); List canUpdateStatus = List.of(RoomStatus.STARTED, RoomStatus.ENDED); if (!canUpdateStatus.contains(roomUpdateStatusRequest.roomStatus())) { @@ -222,4 +237,16 @@ public RoomJoinResponse subscribeRoom(Long roomId, Long memberId) { currentParticipantManager.addParticipant(roomId, memberId, limit); return new RoomJoinResponse(currentParticipantManager.getRoomParticipants(roomId).size()); } + + private void clearCache(Long roomId, String secretCode) { + Cache roomByIdCache = cacheManager.getCache("roomById"); + if (roomByIdCache != null) { + roomByIdCache.evict(roomId); + } + + Cache roomBySecretCodeCache = cacheManager.getCache("roomBySecretCode"); + if (roomBySecretCodeCache != null) { + roomBySecretCodeCache.evict(secretCode); + } + } } From 7c69474bf9b37f65bb1a872507e89f8e49d40096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:30:56 +0900 Subject: [PATCH 70/74] =?UTF-8?q?release:=20=EC=BA=90=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=BD=94=EB=93=9C=20=EB=B3=91=ED=95=A9=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 (#78) * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 * feat: 발표방 캐싱 및 인덱싱 적용 feat 새로운 기능 추가 (#80) * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 * feat: 카카오 로그인 API (#86) * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 * refactor: Presigned URL 업로드용, 조회용 로직 분리 (#84) * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 * refactor: profiles 환경 변수 주입으로 변경 (#90) * feat: 카카오 로그인 재시도 로직, 방 참가자 관리 동시성 수정 (#91) * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 * refactor,fix: 2회차 멘토링 기반 리팩토링 (#94) * fix: MemberController 수정 (#96) * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 * 답변 조회 기능 수정 (#98) * feat: cors 설정 (#102) * Refactor/101 room detail (#104) * feat: cors 설정 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#106) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#108) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#110) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Update WebSocketConfig.java * Refactor/101 room detail (#114) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#116) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * Refactor/101 room detail (#118) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * Refactor/101 room detail (#120) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * test: 질문 생성 테스트 배포 (#122) * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 인터셉터, 핸들러 재추가 * 핸들러 다시 추가 * 핸들러 다시 주석 * sockjs만 살리기 * dev 와 병합 * 질문 생성 변경 * fix: stomp controller 수정 (#125) * fix: Stomp 컨트롤러 수정 * test: 테스트 코드 수정 * fix: 답변 기능 수정 (#128) * fix: 답변 조회 수정 * test: 답변 조회 테스트 수정 * fix: cors 메서드 수정 (#131) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 시큐리티 수정 (#133) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 permitAll 추가 (#135) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 * fix: 답변율 퍼센트 수정 (#138) * fix: 참여한 발표방 조회 시 생성일, 참여일이 나오게 변경 (#141) * fix: 참여한 발표방 조회 시 생성일, 참여일이 나오게 변경 * test: 변경에 따른 테스트 코드 수정 * fix: 캐시 삭제 로직 수정 (#143) * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 * fix: 캐시 삭제 추가 --------- Co-authored-by: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Co-authored-by: chcch529 <146617430+chcch529@users.noreply.github.com> Co-authored-by: SeungTae <122506273+gffd94@users.noreply.github.com> Co-authored-by: 민경준 --- .../join/room/service/RoomService.java | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index cbf3cbe..ee8d2d1 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -1,5 +1,17 @@ package com.oronaminc.join.room.service; +import static com.oronaminc.join.global.exception.ErrorCode.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.oronaminc.join.answer.domain.Answer; import com.oronaminc.join.answer.service.AnswerReader; import com.oronaminc.join.document.domain.Document; @@ -16,22 +28,24 @@ import com.oronaminc.join.room.dao.RoomRepository; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.dto.*; +import com.oronaminc.join.room.dto.CreateRoomRequest; +import com.oronaminc.join.room.dto.CreateRoomResponse; +import com.oronaminc.join.room.dto.JoinRoomRequest; +import com.oronaminc.join.room.dto.JoinRoomResponse; +import com.oronaminc.join.room.dto.ReportResponse; +import com.oronaminc.join.room.dto.RoomDetailResponse; +import com.oronaminc.join.room.dto.RoomJoinResponse; +import com.oronaminc.join.room.dto.RoomUpdateInfoResponse; +import com.oronaminc.join.room.dto.RoomUpdateRequest; +import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; +import com.oronaminc.join.room.dto.TopQnAResponse; import com.oronaminc.join.room.event.RoomDeleteEvent; import com.oronaminc.join.room.util.CodeGenerator; import com.oronaminc.join.room.util.RoomMapper; import com.oronaminc.join.websocket.session.CurrentParticipantManager; -import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; -import static com.oronaminc.join.global.exception.ErrorCode.*; @Service @Transactional(readOnly = true) @@ -49,6 +63,7 @@ public class RoomService { private final RoomReader roomReader; private final CurrentParticipantManager currentParticipantManager; private final ApplicationEventPublisher publisher; + private final CacheManager cacheManager; private static final int CODE_LENGTH = 6; @@ -92,7 +107,6 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { } @Transactional - @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomRequest) { participantService.validatePresenter(roomId, memberId); @@ -105,10 +119,11 @@ public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomR room.update(updateRoomRequest); participantService.updateTeam(room, updateRoomRequest.teamEmail()); documentService.updateDocument(updateRoomRequest.documentUrl(), roomId); + + clearCache(room.getId(), room.getSecretCode()); } @Transactional - @CacheEvict(cacheNames = "roomById", key = "#roomId") public void deleteRoom(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); @@ -119,18 +134,19 @@ public void deleteRoom(Long memberId, Long roomId) { throw new ErrorException(BAD_REQUEST_ROOM_STARTED); } + clearCache(room.getId(), room.getSecretCode()); publisher.publishEvent(new RoomDeleteEvent(roomId)); roomRepository.deleteById(roomId); s3Service.deleteFile(document.getFileUrl()); } @Transactional - @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoomStatus(Long memberId, Long roomId, RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); + clearCache(room.getId(), room.getSecretCode()); RoomStatus updateStatus = roomUpdateStatusRequest.roomStatus(); List canUpdateStatus = List.of(RoomStatus.STARTED, RoomStatus.ENDED); if (!canUpdateStatus.contains(roomUpdateStatusRequest.roomStatus())) { @@ -222,4 +238,16 @@ public RoomJoinResponse subscribeRoom(Long roomId, Long memberId) { currentParticipantManager.addParticipant(roomId, memberId, limit); return new RoomJoinResponse(currentParticipantManager.getRoomParticipants(roomId).size()); } + + private void clearCache(Long roomId, String secretCode) { + Cache roomByIdCache = cacheManager.getCache("roomById"); + if (roomByIdCache != null) { + roomByIdCache.evict(roomId); + } + + Cache roomBySecretCodeCache = cacheManager.getCache("roomBySecretCode"); + if (roomBySecretCodeCache != null) { + roomBySecretCodeCache.evict(secretCode); + } + } } From 1fd33f7355e68c225c0161dabac1d795d083946a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:16:00 +0900 Subject: [PATCH 71/74] =?UTF-8?q?refactor:=20devcontroller=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 에러 코드 변경 * fix: cors 허용 메서드 수정 * fix: 로그인 anonymous 해제 * fix: 시큐리티 컨픽 불필요한 부분 삭제 * fix: 시큐리티 컨픽 permitall 추가 * fix: 캐시 삭제 추가 * refactor: devcontroller 프로필 로컬로 변경 --- .../java/com/oronaminc/join/global/dev/DevController.java | 2 ++ .../java/com/oronaminc/join/global/dev/HealthController.java | 4 ++-- .../com/oronaminc/join/member/security/SecurityConfig.java | 3 ++- .../java/com/oronaminc/join/room/service/RoomService.java | 3 +-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/oronaminc/join/global/dev/DevController.java b/src/main/java/com/oronaminc/join/global/dev/DevController.java index 5ab3347..4eaef57 100644 --- a/src/main/java/com/oronaminc/join/global/dev/DevController.java +++ b/src/main/java/com/oronaminc/join/global/dev/DevController.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -23,6 +24,7 @@ import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +@Profile("local") @RestController @Tag(name = "개발용 API") @RequestMapping("/dev") diff --git a/src/main/java/com/oronaminc/join/global/dev/HealthController.java b/src/main/java/com/oronaminc/join/global/dev/HealthController.java index b4defaa..b03223a 100644 --- a/src/main/java/com/oronaminc/join/global/dev/HealthController.java +++ b/src/main/java/com/oronaminc/join/global/dev/HealthController.java @@ -9,7 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; @RestController -@Tag(name = "개발용 API") +@Tag(name = "헬스체크 API") public class HealthController { @Operation(summary = "애플리케이션 헬스체크") @@ -19,7 +19,7 @@ public String health() { return "Server is Healthy!"; } - @Operation(summary = "홈 헬스체크") + @Operation(summary = "홈") @ResponseStatus(HttpStatus.OK) @GetMapping("/") public String home() { diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 87f65f1..4f3d00d 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -47,7 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/ws/**", "/api/auth/guest", "/api/auth/kakao", - "/login" + "/login", + "/health" ) .permitAll() .anyRequest() diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index ee8d2d1..c06ea32 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -46,7 +46,6 @@ import lombok.RequiredArgsConstructor; - @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -142,7 +141,7 @@ public void deleteRoom(Long memberId, Long roomId) { @Transactional public void updateRoomStatus(Long memberId, Long roomId, - RoomUpdateStatusRequest roomUpdateStatusRequest) { + RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); Room room = roomReader.getById(roomId); From 8b5c5e8e9a9adc80137fa53a3edc72b10fefe524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:04:31 +0900 Subject: [PATCH 72/74] =?UTF-8?q?test:=20=EC=BA=90=EC=8B=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 캐시 테스트 수정 및 방 퇴장 로직 주석 처리 * test: 캐시 테스트 비밀코드 중복 문제 수정 --- .../session/CustomWebSocketHandlerDecorator.java | 2 +- .../oronaminc/join/room/service/RoomCacheTests.java | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/oronaminc/join/websocket/session/CustomWebSocketHandlerDecorator.java b/src/main/java/com/oronaminc/join/websocket/session/CustomWebSocketHandlerDecorator.java index 6d67c1f..509fe9f 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CustomWebSocketHandlerDecorator.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CustomWebSocketHandlerDecorator.java @@ -41,7 +41,7 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { // 세션 연결 종료되면 map에서 제거 - exitRoomPublishEvent(session); + // exitRoomPublishEvent(session); sessionManager.removeSession(session.getId()); super.afterConnectionClosed(session, closeStatus); } diff --git a/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java index 9c92108..fd609d0 100644 --- a/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java +++ b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java @@ -23,6 +23,7 @@ import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.domain.RoomType; import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; +import com.oronaminc.join.room.util.CodeGenerator; @SpringBootTest @ActiveProfiles("test") @@ -48,11 +49,21 @@ class RoomCacheTests { @BeforeEach void setUp() { + String code; + while (true) { + String codeTest = CodeGenerator.generateCode(6); + if (!roomReader.existsBySecretCode(codeTest)) { + code = codeTest; + break; + } + } + Room room = Room.builder() .title("Test Room") .description("Test Description") .roomStatus(RoomStatus.BEFORE_START) .roomType(RoomType.PUBLIC) + .secretCode(code) .build(); roomRepository.save(room); From 1cb0fde39d29503454b41d2404faa9ab9290cbb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=EA=B2=BD=EC=A4=80?= Date: Tue, 26 Aug 2025 14:11:12 +0900 Subject: [PATCH 73/74] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20jwt?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로그인 jwt 적용 * feat: 비회원 로그인 jwt 적용 * chore: 사용 안하는 메서드, dto 제거 * chore: ci yml 주입 디코딩 제거 --- .github/workflows/ci.yml | 2 +- build.gradle | 5 + .../join/Web57OronaminCBeApplication.java | 3 + .../join/global/dev/DevController.java | 139 +++++++++--------- .../join/global/dev/HealthController.java | 56 +++---- .../join/member/dto/SessionInfoResponse.java | 17 --- .../join/member/security/AuthController.java | 97 ++++-------- .../join/member/security/AuthService.java | 58 +++++--- .../join/member/security/SecurityConfig.java | 58 ++++---- .../join/member/token/AuthTokenResponse.java | 13 ++ .../join/member/token/JwtConfiguration.java | 11 ++ .../join/member/token/JwtMemberInfo.java | 10 ++ .../join/member/token/JwtTokenProvider.java | 72 +++++++++ .../oronaminc/join/member/token/JwtUtils.java | 29 ++++ .../join/member/token/LoginResponse.java | 9 ++ .../join/member/token/TokenBody.java | 14 ++ .../join/member/token/TokenPair.java | 9 ++ .../join/member/util/MemberMapper.java | 71 +++------ 18 files changed, 384 insertions(+), 289 deletions(-) delete mode 100644 src/main/java/com/oronaminc/join/member/dto/SessionInfoResponse.java create mode 100644 src/main/java/com/oronaminc/join/member/token/AuthTokenResponse.java create mode 100644 src/main/java/com/oronaminc/join/member/token/JwtConfiguration.java create mode 100644 src/main/java/com/oronaminc/join/member/token/JwtMemberInfo.java create mode 100644 src/main/java/com/oronaminc/join/member/token/JwtTokenProvider.java create mode 100644 src/main/java/com/oronaminc/join/member/token/JwtUtils.java create mode 100644 src/main/java/com/oronaminc/join/member/token/LoginResponse.java create mode 100644 src/main/java/com/oronaminc/join/member/token/TokenBody.java create mode 100644 src/main/java/com/oronaminc/join/member/token/TokenPair.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 238afe2..46e6800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: application-ci.yml 주입 run: | mkdir -p src/main/resources - echo "${{ secrets.APPLICATION_YML_CI }}" | base64 --decode > src/main/resources/application-ci.yml + echo "${{ secrets.APPLICATION_YML_CI }}" > src/main/resources/application-ci.yml - name: 권한 세팅 run: chmod +x ./gradlew diff --git a/build.gradle b/build.gradle index 3fb9bea..997118e 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,11 @@ dependencies { implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'org.springframework.retry:spring-retry:2.0.12' + + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/Web57OronaminCBeApplication.java b/src/main/java/com/oronaminc/join/Web57OronaminCBeApplication.java index 49f9e5b..dddd698 100644 --- a/src/main/java/com/oronaminc/join/Web57OronaminCBeApplication.java +++ b/src/main/java/com/oronaminc/join/Web57OronaminCBeApplication.java @@ -1,11 +1,14 @@ package com.oronaminc.join; +import com.oronaminc.join.member.token.JwtConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication +@EnableConfigurationProperties(JwtConfiguration.class) public class Web57OronaminCBeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/oronaminc/join/global/dev/DevController.java b/src/main/java/com/oronaminc/join/global/dev/DevController.java index 4eaef57..d0baaf5 100644 --- a/src/main/java/com/oronaminc/join/global/dev/DevController.java +++ b/src/main/java/com/oronaminc/join/global/dev/DevController.java @@ -1,73 +1,66 @@ -package com.oronaminc.join.global.dev; - -import java.util.List; - -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import com.oronaminc.join.member.dao.MemberRepository; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.domain.MemberType; -import com.oronaminc.join.member.security.MemberDetails; - -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; -import lombok.RequiredArgsConstructor; - -@Profile("local") -@RestController -@Tag(name = "개발용 API") -@RequestMapping("/dev") -@RequiredArgsConstructor -public class DevController { - private final MemberRepository memberRepository; - - @PostMapping("/join") - @ResponseStatus(HttpStatus.OK) - public Member devJoin(@RequestBody DevJoinRequest devJoinRequest) { - return memberRepository.save( - Member.builder() - .email(devJoinRequest.email()) - .nickname(devJoinRequest.nickname()) - .memberType(MemberType.MEMBER) - .build() - ); - } - - @PostMapping("/login") - @ResponseStatus(HttpStatus.OK) - public void devLogin(@RequestBody DevLoginRequest devLoginRequest, HttpServletRequest request) { - Member member = memberRepository.findById(devLoginRequest.memberId()) - .orElseThrow(() -> new IllegalArgumentException("해당 ID의 사용자가 존재하지 않습니다.")); - - MemberDetails memberDetails = MemberDetails.builder() - .id(member.getId()) - .name(member.getEmail()) - .nickname(member.getNickname()) - .role(member.getMemberType()) - .build(); - - Authentication authentication = new UsernamePasswordAuthenticationToken( - memberDetails, - null, - List.of(new SimpleGrantedAuthority(memberDetails.getRole())) - ); - - SecurityContextHolder.getContext().setAuthentication(authentication); - - HttpSession session = request.getSession(true); - session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()); - } - - -} +//package com.oronaminc.join.global.dev; +// +//import com.oronaminc.join.member.dao.MemberRepository; +//import com.oronaminc.join.member.domain.Member; +//import com.oronaminc.join.member.domain.MemberType; +//import com.oronaminc.join.member.security.MemberDetails; +//import io.swagger.v3.oas.annotations.tags.Tag; +//import jakarta.servlet.http.HttpServletRequest; +//import jakarta.servlet.http.HttpSession; +//import lombok.RequiredArgsConstructor; +//import org.springframework.http.HttpStatus; +//import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +//import org.springframework.security.core.Authentication; +//import org.springframework.security.core.authority.SimpleGrantedAuthority; +//import org.springframework.security.core.context.SecurityContextHolder; +//import org.springframework.web.bind.annotation.*; +// +//import java.util.List; +// +/// /@Profile("local") +//@RestController +//@Tag(name = "개발용 API") +//@RequestMapping("/dev") +//@RequiredArgsConstructor +//public class DevController { +// private final MemberRepository memberRepository; +// +// @PostMapping("/join") +// @ResponseStatus(HttpStatus.OK) +// public Member devJoin(@RequestBody DevJoinRequest devJoinRequest) { +// return memberRepository.save( +// Member.builder() +// .email(devJoinRequest.email()) +// .nickname(devJoinRequest.nickname()) +// .memberType(MemberType.MEMBER) +// .build() +// ); +// } +// +// @PostMapping("/login") +// @ResponseStatus(HttpStatus.OK) +// public void devLogin(@RequestBody DevLoginRequest devLoginRequest, HttpServletRequest request) { +// Member member = memberRepository.findById(devLoginRequest.memberId()) +// .orElseThrow(() -> new IllegalArgumentException("해당 ID의 사용자가 존재하지 않습니다.")); +// +// MemberDetails memberDetails = MemberDetails.builder() +// .id(member.getId()) +// .name(member.getEmail()) +// .nickname(member.getNickname()) +// .role(member.getMemberType()) +// .build(); +// +// Authentication authentication = new UsernamePasswordAuthenticationToken( +// memberDetails, +// null, +// List.of(new SimpleGrantedAuthority(memberDetails.getRole())) +// ); +// +// SecurityContextHolder.getContext().setAuthentication(authentication); +// +// HttpSession session = request.getSession(true); +// session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()); +// } +// +// +//} diff --git a/src/main/java/com/oronaminc/join/global/dev/HealthController.java b/src/main/java/com/oronaminc/join/global/dev/HealthController.java index b03223a..b0582ff 100644 --- a/src/main/java/com/oronaminc/join/global/dev/HealthController.java +++ b/src/main/java/com/oronaminc/join/global/dev/HealthController.java @@ -1,28 +1,28 @@ -package com.oronaminc.join.global.dev; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; - -@RestController -@Tag(name = "헬스체크 API") -public class HealthController { - - @Operation(summary = "애플리케이션 헬스체크") - @ResponseStatus(HttpStatus.OK) - @GetMapping("/health") - public String health() { - return "Server is Healthy!"; - } - - @Operation(summary = "홈") - @ResponseStatus(HttpStatus.OK) - @GetMapping("/") - public String home() { - return "It's Home!"; - } -} +//package com.oronaminc.join.global.dev; +// +//import org.springframework.http.HttpStatus; +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.ResponseStatus; +//import org.springframework.web.bind.annotation.RestController; +// +//import io.swagger.v3.oas.annotations.Operation; +//import io.swagger.v3.oas.annotations.tags.Tag; +// +//@RestController +//@Tag(name = "헬스체크 API") +//public class HealthController { +// +// @Operation(summary = "애플리케이션 헬스체크") +// @ResponseStatus(HttpStatus.OK) +// @GetMapping("/health") +// public String health() { +// return "Server is Healthy!"; +// } +// +// @Operation(summary = "홈") +// @ResponseStatus(HttpStatus.OK) +// @GetMapping("/") +// public String home() { +// return "It's Home!"; +// } +//} diff --git a/src/main/java/com/oronaminc/join/member/dto/SessionInfoResponse.java b/src/main/java/com/oronaminc/join/member/dto/SessionInfoResponse.java deleted file mode 100644 index 44edbd1..0000000 --- a/src/main/java/com/oronaminc/join/member/dto/SessionInfoResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.oronaminc.join.member.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "유효한 세션으로 로그인한 사용자 정보 응답 DTO") -public record SessionInfoResponse( - @Schema(description = "Kakao 회원 ID", example = "1") - Long id, - @Schema(description = "Kakao 회원 이름", example = "카카오") - String name, - @Schema(description = "Kakao 회원 닉네임", example = "kakao") - String nickname, - @Schema(description = "Kakao 회원 역할", example = "MEMBER") - String role -) { - -} diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 79e6eac..5dbd2de 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -1,31 +1,11 @@ package com.oronaminc.join.member.security; -import static com.oronaminc.join.member.util.MemberMapper.toSessionInfoResponse; - -import java.util.List; - -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - import com.oronaminc.join.member.dto.GuestLoginRequest; -import com.oronaminc.join.member.dto.GuestLoginResponse; import com.oronaminc.join.member.dto.KakaoLoginRequest; -import com.oronaminc.join.member.dto.KakaoLoginResponse; -import com.oronaminc.join.member.dto.SessionInfoResponse; - +import com.oronaminc.join.member.token.AuthTokenResponse; +import com.oronaminc.join.member.token.JwtUtils; +import com.oronaminc.join.member.token.LoginResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,45 +14,45 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/auth") @Tag(name = "Auth", description = "로그인 관련 API") @RequiredArgsConstructor public class AuthController { + private final AuthService authService; @Operation( - summary = "카카오 로그인", - description = "redirect url 에 포함된 파라미터의 code와 state를 입력해주세요. 이후 모든 요청에 세션 인증이 적용됩니다." + summary = "카카오 로그인" ) @PostMapping("/kakao") @ResponseStatus(HttpStatus.OK) - public SessionInfoResponse kakaoLogin( - @RequestBody KakaoLoginRequest kakaoLoginRequest, - HttpServletRequest request + public Map kakaoLogin( + @RequestBody KakaoLoginRequest kakaoLoginRequest, + HttpServletResponse response ) { - MemberDetails memberDetails = authService.kakaoLogin(kakaoLoginRequest.code()); + LoginResponse loginResponse = authService.kakaoLogin(kakaoLoginRequest.code()); - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - memberDetails, null, List.of(new SimpleGrantedAuthority(memberDetails.getRole())) - ); + String refreshToken = loginResponse.refreshToken(); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); - SecurityContextHolder.setContext(context); + JwtUtils.addRefreshTokenCookie(response, refreshToken, + loginResponse.refreshTokenExpiresIn()); - SecurityContextHolder.getContext().setAuthentication(authentication); - - request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - - return toSessionInfoResponse(memberDetails); + return Map.of("token", loginResponse.authTokenResponse()); } @Operation( summary = "비회원 로그인", - description = "닉네임을 입력하면 비회원 세션이 생성되고 인증이 설정됩니다. 이후 모든 요청에 세션 인증이 적용됩니다.", responses = { @ApiResponse(responseCode = "201", description = "비회원 로그인 성공"), @ApiResponse(responseCode = "400", description = "닉네임 누락 또는 유효성 검증 실패") @@ -80,36 +60,17 @@ public SessionInfoResponse kakaoLogin( ) @PostMapping("/guest") @ResponseStatus(HttpStatus.CREATED) - public SessionInfoResponse guestLogin(@RequestBody @Valid GuestLoginRequest guestLoginRequest, HttpServletRequest request) { - MemberDetails guest = authService.loadGuest(guestLoginRequest); - - Authentication authentication = new UsernamePasswordAuthenticationToken( - guest, null, List.of(new SimpleGrantedAuthority(guest.getRole())) - ); - - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); - SecurityContextHolder.setContext(context); + public Map guestLogin( + @RequestBody @Valid GuestLoginRequest guestLoginRequest, + HttpServletResponse response) { + LoginResponse loginResponse = authService.loadGuest(guestLoginRequest); - request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + String refreshToken = loginResponse.refreshToken(); - return toSessionInfoResponse(guest); - } - - @Operation( - summary = "현재 세션 사용자 정보 조회", - description = "로그인한 사용자의 세션 정보를 반환합니다. 로그인하지 않은 경우 403 또는 401이 발생합니다.", - responses = { - @ApiResponse(responseCode = "200", description = "세션 사용자 정보 조회 성공"), - @ApiResponse(responseCode = "401", description = "로그인되지 않은 사용자"), - @ApiResponse(responseCode = "403", description = "인증된 사용자 아님") - } - ) - @GetMapping("/session") - @ResponseStatus(HttpStatus.OK) - public SessionInfoResponse getSessionInfo(@AuthenticationPrincipal MemberDetails memberDetails) { + JwtUtils.addRefreshTokenCookie(response, refreshToken, + loginResponse.refreshTokenExpiresIn()); - return toSessionInfoResponse(memberDetails); + return Map.of("token", loginResponse.authTokenResponse()); } @Operation( diff --git a/src/main/java/com/oronaminc/join/member/security/AuthService.java b/src/main/java/com/oronaminc/join/member/security/AuthService.java index 3d64d9f..dbc5667 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthService.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthService.java @@ -1,9 +1,21 @@ package com.oronaminc.join.member.security; -import static com.oronaminc.join.member.util.MemberMapper.*; +import static com.oronaminc.join.member.util.MemberMapper.toGuestMember; +import com.oronaminc.join.member.dao.MemberRepository; +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.dto.KakaoUserResponse; +import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.member.token.AuthTokenResponse; +import com.oronaminc.join.member.token.JwtMemberInfo; +import com.oronaminc.join.member.token.JwtTokenProvider; +import com.oronaminc.join.member.token.LoginResponse; +import com.oronaminc.join.member.token.TokenPair; +import com.oronaminc.join.member.util.MemberMapper; import java.util.Map; - +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -17,22 +29,14 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; -import com.oronaminc.join.member.dao.MemberRepository; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.dto.GuestLoginRequest; -import com.oronaminc.join.member.dto.KakaoUserResponse; -import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.member.util.MemberMapper; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - @Service @Slf4j @RequiredArgsConstructor public class AuthService extends DefaultOAuth2UserService { + private final MemberRepository memberRepository; private final MemberReader memberReader; + private final JwtTokenProvider jwtTokenProvider; private final RestTemplate restTemplate = new RestTemplate(); @@ -67,24 +71,41 @@ public class AuthService extends DefaultOAuth2UserService { // } @Transactional - public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { + public LoginResponse loadGuest(GuestLoginRequest guestLoginRequest) { Member guest = toGuestMember(guestLoginRequest); memberRepository.save(guest); guest.registerGuest(); - return toGuestMemberDetails(guest); + TokenPair tokenPair = jwtTokenProvider.generateTokenPair( + new JwtMemberInfo(guest.getId(), guest.getNickname(), guest.getMemberType())); + + AuthTokenResponse authTokenResponse = new AuthTokenResponse(tokenPair.accessToken(), + tokenPair.accessTokenExpiresIn(), guest.getId(), + guest.getNickname(), guest.getMemberType()); + + return new LoginResponse(authTokenResponse, tokenPair.refreshToken(), + tokenPair.refreshTokenExpiresIn()); } @Transactional - public MemberDetails kakaoLogin(String code) { + public LoginResponse kakaoLogin(String code) { String accessToken = getAccessToken(code); KakaoUserResponse kakaoUser = getUserInfo(accessToken); Member member = memberRepository.findByEmail(kakaoUser.email()) - .orElseGet(() -> memberRepository.save(MemberMapper.toNewKakaoMember(kakaoUser))); + .orElseGet(() -> memberRepository.save(MemberMapper.toNewKakaoMember(kakaoUser))); + + TokenPair tokenPair = jwtTokenProvider.generateTokenPair( + new JwtMemberInfo(member.getId(), member.getNickname(), member.getMemberType())); + + AuthTokenResponse authTokenResponse = new AuthTokenResponse(tokenPair.accessToken(), + tokenPair.accessTokenExpiresIn(), member.getId(), + member.getNickname(), member.getMemberType()); + + return new LoginResponse(authTokenResponse, tokenPair.refreshToken(), + tokenPair.refreshTokenExpiresIn()); - return toOAuth2MemberDetails(member); } private String getAccessToken(String code) { @@ -112,7 +133,8 @@ private KakaoUserResponse getUserInfo(String accessToken) { HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange(USER_INFO_URI, HttpMethod.GET, entity, Map.class); + ResponseEntity response = restTemplate.exchange(USER_INFO_URI, HttpMethod.GET, entity, + Map.class); Map attributes = response.getBody(); diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 4f3d00d..c37b66f 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -1,12 +1,8 @@ package com.oronaminc.join.member.security; -import static org.springframework.security.config.Customizer.*; - -import java.util.List; - +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -15,10 +11,12 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import lombok.RequiredArgsConstructor; +import java.util.List; + +import static org.springframework.security.config.Customizer.withDefaults; @Configuration -@Profile("!test") +//@Profile("!test") @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -30,29 +28,29 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth - // .requestMatchers( - // "/api/auth/guest", - // "/api/auth/kakao", - // "/login" - // ) - // .anonymous() - .requestMatchers( - "/swagger-ui/**", - "/swagger-resources/**", - "/v3/api-docs/**", - "/oauth2/authorization/**", - "/login/oauth2/code/kakao", - "/api/auth/logout", - "/dev/**", - "/ws/**", - "/api/auth/guest", - "/api/auth/kakao", - "/login", - "/health" - ) - .permitAll() - .anyRequest() - .authenticated() + // .requestMatchers( + // "/api/auth/guest", + // "/api/auth/kakao", + // "/login" + // ) + // .anonymous() + .requestMatchers( + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/oauth2/**", + "/login/oauth2/code/kakao", + "/api/auth/logout", + "/dev/**", + "/ws/**", + "/api/auth/guest", + "/api/auth/kakao", + "/login" +// "/health" + ) + .permitAll() + .anyRequest() + .authenticated() ) .formLogin(AbstractHttpConfigurer::disable) .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo diff --git a/src/main/java/com/oronaminc/join/member/token/AuthTokenResponse.java b/src/main/java/com/oronaminc/join/member/token/AuthTokenResponse.java new file mode 100644 index 0000000..56b58fc --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/AuthTokenResponse.java @@ -0,0 +1,13 @@ +package com.oronaminc.join.member.token; + +import com.oronaminc.join.member.domain.MemberType; + +public record AuthTokenResponse( + String accessToken, + long accessTokenExpiresIn, + Long memberId, + String nickname, + MemberType role +) { + +} diff --git a/src/main/java/com/oronaminc/join/member/token/JwtConfiguration.java b/src/main/java/com/oronaminc/join/member/token/JwtConfiguration.java new file mode 100644 index 0000000..b6d397a --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/JwtConfiguration.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.member.token; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtConfiguration( + String secret, + Long accessTokenExpiration, + Long refreshTokenExpiration +) { +} diff --git a/src/main/java/com/oronaminc/join/member/token/JwtMemberInfo.java b/src/main/java/com/oronaminc/join/member/token/JwtMemberInfo.java new file mode 100644 index 0000000..d876e4b --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/JwtMemberInfo.java @@ -0,0 +1,10 @@ +package com.oronaminc.join.member.token; + +import com.oronaminc.join.member.domain.MemberType; + +public record JwtMemberInfo( + Long memberId, + String nickname, + MemberType role +) { +} diff --git a/src/main/java/com/oronaminc/join/member/token/JwtTokenProvider.java b/src/main/java/com/oronaminc/join/member/token/JwtTokenProvider.java new file mode 100644 index 0000000..f774e78 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/JwtTokenProvider.java @@ -0,0 +1,72 @@ +package com.oronaminc.join.member.token; + +import com.oronaminc.join.member.domain.MemberType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.util.Date; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtConfiguration jwtConfiguration; + + public TokenPair generateTokenPair(JwtMemberInfo jwtMemberInfo) { + + String accessToken = issueAccessToken(jwtMemberInfo); + String refreshToken = issueRefreshToken(jwtMemberInfo); + + return new TokenPair(accessToken, refreshToken, + jwtConfiguration.accessTokenExpiration(), jwtConfiguration.refreshTokenExpiration()); + } + + private String issueAccessToken(JwtMemberInfo jwtMemberInfo) { + return issue(jwtMemberInfo, jwtConfiguration.accessTokenExpiration()); + } + + private String issueRefreshToken(JwtMemberInfo jwtMemberInfo) { + return issue(jwtMemberInfo, jwtConfiguration.refreshTokenExpiration()); + } + + private String issue(JwtMemberInfo jwtMemberInfo, Long expTime) { + return Jwts.builder() + .subject(jwtMemberInfo.memberId().toString()) + .claim("nickname", jwtMemberInfo.nickname()) + .claim("role", jwtMemberInfo.role()) + .issuedAt(new Date()) + .expiration(new Date(new Date().getTime() + expTime)) + .signWith(getSecretKey(), Jwts.SIG.HS256) + .compact(); + } + + public TokenBody parseClaims(String token) { + + Jws claims = Jwts.parser() + .verifyWith(getSecretKey()) + .build() + .parseSignedClaims(token); + + Claims payload = claims.getPayload(); + + Long memberId = Long.parseLong(payload.getSubject()); + + return new TokenBody( + memberId, + payload.get("nickname").toString(), + MemberType.valueOf(payload.get("role").toString()), + payload.getIssuedAt(), + payload.getExpiration() + ); + } + + private SecretKey getSecretKey() { + return Keys.hmacShaKeyFor(jwtConfiguration.secret().getBytes()); + } +} \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/member/token/JwtUtils.java b/src/main/java/com/oronaminc/join/member/token/JwtUtils.java new file mode 100644 index 0000000..693c79f --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/JwtUtils.java @@ -0,0 +1,29 @@ +package com.oronaminc.join.member.token; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JwtUtils { + + public static long toSeconds(long millis) { + return millis / 1000; + } + + public static void addRefreshTokenCookie(HttpServletResponse response, String refreshToken, + long expiresIn) { + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .sameSite("Strict") + .maxAge(JwtUtils.toSeconds(expiresIn)) + .build(); + + response.setHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + } + +} diff --git a/src/main/java/com/oronaminc/join/member/token/LoginResponse.java b/src/main/java/com/oronaminc/join/member/token/LoginResponse.java new file mode 100644 index 0000000..f1e32da --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/LoginResponse.java @@ -0,0 +1,9 @@ +package com.oronaminc.join.member.token; + +public record LoginResponse( + AuthTokenResponse authTokenResponse, + String refreshToken, + long refreshTokenExpiresIn +) { + +} diff --git a/src/main/java/com/oronaminc/join/member/token/TokenBody.java b/src/main/java/com/oronaminc/join/member/token/TokenBody.java new file mode 100644 index 0000000..98d5df2 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/TokenBody.java @@ -0,0 +1,14 @@ +package com.oronaminc.join.member.token; + +import com.oronaminc.join.member.domain.MemberType; + +import java.util.Date; + +public record TokenBody( + Long memberId, + String nickname, + MemberType role, + Date issuedAt, + Date expiration +) { +} diff --git a/src/main/java/com/oronaminc/join/member/token/TokenPair.java b/src/main/java/com/oronaminc/join/member/token/TokenPair.java new file mode 100644 index 0000000..928803c --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/TokenPair.java @@ -0,0 +1,9 @@ +package com.oronaminc.join.member.token; + +public record TokenPair( + String accessToken, + String refreshToken, + long accessTokenExpiresIn, + long refreshTokenExpiresIn +) { +} diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java index 831d3c1..bc85917 100644 --- a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -1,78 +1,41 @@ package com.oronaminc.join.member.util; -import com.oronaminc.join.member.dto.SessionInfoResponse; -import java.util.Map; - import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.dto.GuestLoginRequest; import com.oronaminc.join.member.dto.KakaoUserResponse; -import com.oronaminc.join.member.security.MemberDetails; - +import java.util.Map; import lombok.AccessLevel; import lombok.AllArgsConstructor; @AllArgsConstructor(access = AccessLevel.PRIVATE) public class MemberMapper { - public static MemberDetails toOAuth2MemberDetails(Member member) { - return MemberDetails.builder() - .id(member.getId()) - .name(member.getEmail()) - .nickname(member.getNickname()) - .role(member.getMemberType()) - .build(); - } - - public static MemberDetails toGuestMemberDetails(Member guest) { - return MemberDetails.builder() - .id(guest.getId()) - .name(guest.getEmail()) - .nickname(guest.getNickname()) - .role(MemberType.GUEST) - .build(); - } public static Member toGuestMember(GuestLoginRequest guestLoginRequest) { return Member.builder() - .email(null) - .nickname(guestLoginRequest.nickname()) - .profileImage(null) - .memberType(MemberType.GUEST) - .build(); - } - - public static Member toKakaoMember(Map kakaoAccount, Map profile) { - return Member.builder() - .email(kakaoAccount.get("email").toString()) - .nickname(profile.get("nickname").toString()) - .profileImage(profile.get("profile_image_url").toString()) - .memberType(MemberType.MEMBER) - .build(); + .email(null) + .nickname(guestLoginRequest.nickname()) + .profileImage(null) + .memberType(MemberType.GUEST) + .build(); } public static Member toNewKakaoMember(KakaoUserResponse kakaoUser) { return Member.builder() - .email(kakaoUser.email()) - .nickname(kakaoUser.nickname()) - .profileImage(kakaoUser.profileImageUrl()) - .memberType(MemberType.MEMBER) - .build(); + .email(kakaoUser.email()) + .nickname(kakaoUser.nickname()) + .profileImage(kakaoUser.profileImageUrl()) + .memberType(MemberType.MEMBER) + .build(); } - public static KakaoUserResponse toKakaoUserResponse(Map kakaoAccount, Map profile) { + public static KakaoUserResponse toKakaoUserResponse(Map kakaoAccount, + Map profile) { return KakaoUserResponse.builder() - .email((String) kakaoAccount.get("email")) - .nickname((String) profile.get("nickname")) - .profileImageUrl((String) profile.get("profile_image_url")) - .build(); + .email((String) kakaoAccount.get("email")) + .nickname((String) profile.get("nickname")) + .profileImageUrl((String) profile.get("profile_image_url")) + .build(); } - public static SessionInfoResponse toSessionInfoResponse(MemberDetails memberDetails) { - return new SessionInfoResponse( - memberDetails.getId(), - memberDetails.getName(), - memberDetails.getNickname(), - memberDetails.getRole() - ); - } } From 0c5fcb39f1657982ad65d504083b092ac74febba Mon Sep 17 00:00:00 2001 From: SeungTae <122506273+gffd94@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:03:46 +0900 Subject: [PATCH 74/74] Feat/151 refreshtoken blacklist (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refreshToken, Blacklist 관리 추가 * samesite 속성변경 --------- Co-authored-by: STGRAM\gffd9 <29088981l@gmail.com> --- .../join/global/config/CacheType.java | 11 ++-- .../join/member/security/AuthController.java | 33 ++++++++++-- .../join/member/security/AuthService.java | 5 ++ .../security/JwtAuthenticationFilter.java | 47 +++++++++++++++++ .../join/member/security/SecurityConfig.java | 6 ++- .../join/member/security/TokenController.java | 52 +++++++++++++++++++ .../oronaminc/join/member/token/JwtUtils.java | 2 +- .../join/member/token/RefreshTokenStore.java | 38 ++++++++++++++ 8 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/oronaminc/join/member/security/TokenController.java create mode 100644 src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java diff --git a/src/main/java/com/oronaminc/join/global/config/CacheType.java b/src/main/java/com/oronaminc/join/global/config/CacheType.java index 71af6e1..74f0d11 100644 --- a/src/main/java/com/oronaminc/join/global/config/CacheType.java +++ b/src/main/java/com/oronaminc/join/global/config/CacheType.java @@ -5,10 +5,11 @@ @AllArgsConstructor public enum CacheType { ROOM_BY_ID("roomById", 300, 1000), - ROOM_BY_SECRET_CODE("roomBySecretCode", 300, 1000) - ; - + ROOM_BY_SECRET_CODE("roomBySecretCode", 300, 1000), + REFRESH_LATEST("refreshLatest", 60 * 60 * 24 * 14, 100_000), // 14일 + REFRESH_BLACKLIST("refreshBlacklist", 60 * 60 * 24 * 14, 100_000); public final String cacheName; - public final long expireAfterWrite; - public final long maximumSize; + public final int expireAfterWrite; + public final int maximumSize; + } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 5dbd2de..2b8d12f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -4,19 +4,21 @@ import com.oronaminc.join.member.dto.GuestLoginRequest; import com.oronaminc.join.member.dto.KakaoLoginRequest; import com.oronaminc.join.member.token.AuthTokenResponse; +import com.oronaminc.join.member.token.JwtTokenProvider; import com.oronaminc.join.member.token.JwtUtils; import com.oronaminc.join.member.token.LoginResponse; +import com.oronaminc.join.member.token.RefreshTokenStore; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -31,6 +33,8 @@ public class AuthController { private final AuthService authService; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; @Operation( summary = "카카오 로그인" @@ -84,7 +88,30 @@ public Map guestLogin( @PostMapping("/logout") @ResponseStatus(HttpStatus.NO_CONTENT) public void logout(HttpServletRequest request, HttpServletResponse response) { - HttpSession session = request.getSession(); + + String refresh = null; + if(request.getCookies() != null){ + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) refresh = cookie.getValue(); + } + } + + if (refresh != null) { + try { + var body = jwtTokenProvider.parseClaims(refresh); + refreshTokenStore.isBlacklisted(refresh); + refreshTokenStore.saveLatest(body.memberId(), ""); + }catch (Exception ignored){ } + } + + // 쿠키 제거 + ResponseCookie expired = ResponseCookie.from("refreshToken", "") + .httpOnly(true).secure(true).sameSite("None") + .path("/").maxAge(0).build(); + + SecurityContextHolder.clearContext(); + + /*HttpSession session = request.getSession(); if (session != null) { session.invalidate(); } @@ -95,6 +122,6 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setMaxAge(0); - response.addCookie(cookie); + response.addCookie(cookie);*/ } } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthService.java b/src/main/java/com/oronaminc/join/member/security/AuthService.java index dbc5667..6e681db 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthService.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthService.java @@ -11,6 +11,7 @@ import com.oronaminc.join.member.token.JwtMemberInfo; import com.oronaminc.join.member.token.JwtTokenProvider; import com.oronaminc.join.member.token.LoginResponse; +import com.oronaminc.join.member.token.RefreshTokenStore; import com.oronaminc.join.member.token.TokenPair; import com.oronaminc.join.member.util.MemberMapper; import java.util.Map; @@ -37,6 +38,7 @@ public class AuthService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; private final MemberReader memberReader; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; private final RestTemplate restTemplate = new RestTemplate(); @@ -79,6 +81,7 @@ public LoginResponse loadGuest(GuestLoginRequest guestLoginRequest) { TokenPair tokenPair = jwtTokenProvider.generateTokenPair( new JwtMemberInfo(guest.getId(), guest.getNickname(), guest.getMemberType())); + refreshTokenStore.saveLatest(guest.getId(), tokenPair.refreshToken()); AuthTokenResponse authTokenResponse = new AuthTokenResponse(tokenPair.accessToken(), tokenPair.accessTokenExpiresIn(), guest.getId(), @@ -99,6 +102,8 @@ public LoginResponse kakaoLogin(String code) { TokenPair tokenPair = jwtTokenProvider.generateTokenPair( new JwtMemberInfo(member.getId(), member.getNickname(), member.getMemberType())); + refreshTokenStore.saveLatest(member.getId(), tokenPair.refreshToken()); + AuthTokenResponse authTokenResponse = new AuthTokenResponse(tokenPair.accessToken(), tokenPair.accessTokenExpiresIn(), member.getId(), member.getNickname(), member.getMemberType()); diff --git a/src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java b/src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a2ccc3f --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java @@ -0,0 +1,47 @@ +package com.oronaminc.join.member.security; + +import com.oronaminc.join.member.token.JwtTokenProvider; +import com.oronaminc.join.member.token.TokenBody; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.HttpHeaders; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + TokenBody body = jwtTokenProvider.parseClaims(token); + var auth = new UsernamePasswordAuthenticationToken( + body.memberId(), + null, + List.of(new SimpleGrantedAuthority(body.role().name())) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + }catch (Exception e){ + + } + } + + filterChain.doFilter(request,response); + } +} diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index c37b66f..498a3aa 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -1,5 +1,6 @@ package com.oronaminc.join.member.security; +import com.oronaminc.join.member.token.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,6 +8,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -23,7 +25,7 @@ public class SecurityConfig { private final AuthService authService; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwt) throws Exception { return http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) @@ -55,6 +57,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo .userService(authService))) + .addFilterBefore(new JwtAuthenticationFilter(jwt), + UsernamePasswordAuthenticationFilter.class) .logout(withDefaults()) .build(); } diff --git a/src/main/java/com/oronaminc/join/member/security/TokenController.java b/src/main/java/com/oronaminc/join/member/security/TokenController.java new file mode 100644 index 0000000..e01634c --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/security/TokenController.java @@ -0,0 +1,52 @@ +package com.oronaminc.join.member.security; + +import com.oronaminc.join.member.token.AuthTokenResponse; +import com.oronaminc.join.member.token.JwtMemberInfo; +import com.oronaminc.join.member.token.JwtTokenProvider; +import com.oronaminc.join.member.token.JwtUtils; +import com.oronaminc.join.member.token.RefreshTokenStore; +import com.oronaminc.join.member.token.TokenBody; +import com.oronaminc.join.member.token.TokenPair; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth/token") +@RequiredArgsConstructor +public class TokenController { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; + + @PostMapping("/refresh") + public AuthTokenResponse refresh(HttpServletRequest request, HttpServletResponse response) { + String refresh = extraRefreshCookie(request); + if (refresh == null) throw new IllegalArgumentException("refresh cookie is null"); + + if (refreshTokenStore.isBlacklisted(refresh)) throw new IllegalArgumentException("refresh cookie is blacklisted"); + + TokenBody body = jwtTokenProvider.parseClaims(refresh); + if (!refreshTokenStore.isLatest(body.memberId(), refresh)) throw new IllegalArgumentException("refresh token is invalid"); + + TokenPair tokenPair = jwtTokenProvider.generateTokenPair(new JwtMemberInfo(body.memberId(), + body.nickname(), body.role())); + refreshTokenStore.isBlacklisted(refresh); + refreshTokenStore.saveLatest(body.memberId(), tokenPair.refreshToken()); + JwtUtils.addRefreshTokenCookie(response, tokenPair.refreshToken(), tokenPair.refreshTokenExpiresIn()); + + return new AuthTokenResponse(tokenPair.accessToken(), tokenPair.accessTokenExpiresIn(), body.memberId(), body.nickname(), body.role()); + } + + private String extraRefreshCookie(HttpServletRequest request) { + if (request.getCookies() == null) return null; + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) return cookie.getValue(); + } + return null; + } +} diff --git a/src/main/java/com/oronaminc/join/member/token/JwtUtils.java b/src/main/java/com/oronaminc/join/member/token/JwtUtils.java index 693c79f..22fad53 100644 --- a/src/main/java/com/oronaminc/join/member/token/JwtUtils.java +++ b/src/main/java/com/oronaminc/join/member/token/JwtUtils.java @@ -19,7 +19,7 @@ public static void addRefreshTokenCookie(HttpServletResponse response, String re .httpOnly(true) .secure(true) .path("/") - .sameSite("Strict") + .sameSite("None") .maxAge(JwtUtils.toSeconds(expiresIn)) .build(); diff --git a/src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java b/src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java new file mode 100644 index 0000000..70586be --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java @@ -0,0 +1,38 @@ +package com.oronaminc.join.member.token; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RefreshTokenStore { + private final CacheManager cacheManager; + + private Cache latest() { + return cacheManager.getCache("refreshLatest"); + } + private Cache blacklist() { + return cacheManager.getCache("refreshBlacklist"); + } + + public void saveLatest(Long memberId, String refreshToken) { + latest().put(key(memberId), refreshToken); + } + + public boolean isLatest(Long memberId, String refreshToken) { + String stored = latest().get(key(memberId), String.class); + return Objects.equals(stored, refreshToken); + } + + public boolean isBlacklisted(String refreshToken) { + Boolean v = blacklist().get(refreshToken, Boolean.class); + return v != null && v; + } + + private String key(Long memberId) { + return "refresh:" + memberId; + } +}