From b4e4067f9610f8c845ceadf905654aa0275c0c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:36:39 +0900 Subject: [PATCH 01/55] Merge pull request #408 from BCSDLab/test/406-upload-api-integration-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test: 업로드 관련 통합 테스트 작성 --- .../domain/upload/UploadApiTest.java | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java new file mode 100644 index 00000000..987b5fce --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -0,0 +1,244 @@ +package gg.agit.konect.integration.domain.upload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import com.jayway.jsonpath.JsonPath; + +import gg.agit.konect.domain.upload.enums.UploadTarget; +import gg.agit.konect.support.IntegrationTestSupport; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +class UploadApiTest extends IntegrationTestSupport { + + private static final int LOGIN_USER_ID = 2024001001; + private static final int MAX_UPLOAD_BYTES = 20 * 1024 * 1024; + + @MockitoBean + private S3Client s3Client; + + @BeforeEach + void setUp() throws Exception { + mockLoginUser(LOGIN_USER_ID); + } + + @Nested + @DisplayName("POST /upload/image - 이미지 업로드") + class UploadImage { + + @Test + @DisplayName("지원하는 이미지를 업로드하면 webp key와 CDN URL을 반환한다") + void uploadImageSuccess() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + String fileUrl = JsonPath.read(responseBody, "$.fileUrl"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".webp"); + assertThat(fileUrl).isEqualTo("https://cdn.test.com/" + key); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().bucket()).isEqualTo("test-bucket"); + assertThat(requestCaptor.getValue().key()).isEqualTo(key); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + } + + @Test + @DisplayName("빈 파일을 업로드하면 400을 반환한다") + void uploadEmptyFileFails() throws Exception { + // given + MockMultipartFile file = imageFile("empty.png", "image/png", new byte[0]); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("허용하지 않는 content type이면 400을 반환한다") + void uploadImageWithInvalidContentTypeFails() throws Exception { + // given + MockMultipartFile file = imageFile("note.txt", "text/plain", "not-image".getBytes()); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_FILE_CONTENT_TYPE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("content type 이 없으면 400을 반환한다") + void uploadImageWithoutContentTypeFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", null, createPngBytes(8, 8)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_FILE_CONTENT_TYPE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("content type 이 비어 있으면 400을 반환한다") + void uploadImageWithBlankContentTypeFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", " ", createPngBytes(8, 8)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_FILE_CONTENT_TYPE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("최대 업로드 크기를 넘기면 400을 반환한다") + void uploadImageWithTooLargeFileFails() throws Exception { + // given + MockMultipartFile file = imageFile( + "large.png", + "image/png", + new byte[MAX_UPLOAD_BYTES + 1] + ); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_FILE_SIZE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("target 파라미터가 없으면 400을 반환한다") + void uploadImageWithoutTargetFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + + // when & then + uploadImageWithoutTarget(file) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("MISSING_REQUIRED_PARAMETER")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("지원하지 않는 target 이면 400을 반환한다") + void uploadImageWithInvalidTargetFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + + // when & then + uploadImage(file, "INVALID") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TYPE_VALUE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("S3 업로드에 실패하면 500을 반환한다") + void uploadImageWhenS3FailsReturnsInternalServerError() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + willThrow(S3Exception.builder().statusCode(500).message("upload failed").build()) + .given(s3Client) + .putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("FAILED_UPLOAD_FILE")); + } + + @Test + @DisplayName("S3 클라이언트 오류가 발생하면 500을 반환한다") + void uploadImageWhenS3ClientFailsReturnsInternalServerError() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + willThrow(SdkClientException.create("network failure")) + .given(s3Client) + .putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("FAILED_UPLOAD_FILE")); + } + } + + private ResultActions uploadImage(MockMultipartFile file, UploadTarget target) throws Exception { + return uploadImage(file, target.name()); + } + + private ResultActions uploadImage(MockMultipartFile file, String target) throws Exception { + return mockMvc.perform( + multipart("/upload/image") + .file(file) + .param("target", target) + ); + } + + private ResultActions uploadImageWithoutTarget(MockMultipartFile file) throws Exception { + return mockMvc.perform( + multipart("/upload/image") + .file(file) + ); + } + + private MockMultipartFile imageFile(String fileName, String contentType, byte[] bytes) { + return new MockMultipartFile("file", fileName, contentType, bytes); + } + + private byte[] createPngBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", outputStream); + return outputStream.toByteArray(); + } + } +} From 696bf3aed760961adc5c258ad340236725363907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:30:27 +0900 Subject: [PATCH 02/55] =?UTF-8?q?test:=20=EC=B1=84=ED=8C=85=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=20(#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 채팅 API 통합 테스트 작성 * test: 엣지 케이스 보강 * refactor: 채팅 통합 테스트의 fixture 범위를 시나리오별 의존성에 맞게 분리 * test: 일반 채팅방 생성이 사용자 쌍당 하나만 유지되도록 계약을 고정 * test: 채팅방 뮤트 해제 검증을 최종 상태 기반으로 수정 --- .../integration/domain/chat/ChatApiTest.java | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java new file mode 100644 index 00000000..d5037be7 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -0,0 +1,342 @@ +package gg.agit.konect.integration.domain.chat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatMessageRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.service.NotificationService; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatApiTest extends IntegrationTestSupport { + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMessageRepository chatMessageRepository; + + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Autowired + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @MockitoBean + private ChatPresenceService chatPresenceService; + + @MockitoBean + private NotificationService notificationService; + + private User adminUser; + private User normalUser; + private User targetUser; + private User outsiderUser; + private University university; + + @BeforeEach + void setUp() { + university = persist(UniversityFixture.create()); + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + clearPersistenceContext(); + } + + @Nested + @DisplayName("POST /chats/rooms - 일반 채팅방 생성") + class CreateDirectChatRoom { + + @BeforeEach + void setUpDirectChatFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("일반 채팅방을 생성한다") + void createDirectChatRoomSuccess() throws Exception { + // given + long beforeCount = countDirectRoomsBetween(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").isNumber()); + + clearPersistenceContext(); + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId())).isPresent(); + assertThat(countDirectRoomsBetween(normalUser, targetUser)).isEqualTo(beforeCount + 1); + } + + @Test + @DisplayName("자기 자신과 채팅방을 만들면 400을 반환한다") + void createDirectChatRoomWithSelfFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(normalUser.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_CREATE_CHAT_ROOM_WITH_SELF")); + } + + @Test + @DisplayName("존재하지 않는 대상 유저면 404를 반환한다") + void createDirectChatRoomWithMissingUserFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(99999)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_USER")); + } + + @Test + @DisplayName("이미 같은 상대와 채팅방이 있으면 기존 방을 반환한다") + void createDirectChatRoomReturnsExistingRoom() throws Exception { + // given + ChatRoom existingRoom = createDirectChatRoom(normalUser, targetUser); + long beforeCount = countDirectRoomsBetween(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(existingRoom.getId())); + + clearPersistenceContext(); + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId())) + .isPresent() + .get() + .extracting(ChatRoom::getId) + .isEqualTo(existingRoom.getId()); + assertThat(countDirectRoomsBetween(normalUser, targetUser)).isEqualTo(beforeCount); + assertThat(chatRoomMemberRepository.findByChatRoomId(existingRoom.getId())).hasSize(2); + } + } + + @Nested + @DisplayName("POST /chats/rooms/admin, GET /chats/rooms - 관리자 전용 방 생성 및 조회") + class AdminChatRoom { + + @BeforeEach + void setUpAdminChatFixture() { + adminUser = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + } + + @Test + @DisplayName("관리자 전용 방을 만들고 목록에서 조회한다") + void createAdminChatRoomAndGetRoomsSuccess() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").isNumber()); + + // then + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].chatType").value("DIRECT")) + .andExpect(jsonPath("$.rooms[0].roomName").value(adminUser.getName())) + .andExpect(jsonPath("$.rooms[0].lastMessage").doesNotExist()) + .andExpect(jsonPath("$.rooms[0].isMuted").value(false)); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/messages - 메시지 전송") + class SendMessage { + + @BeforeEach + void setUpMessageFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("메시지를 전송하고 응답 형태를 반환한다") + void sendMessageSuccess() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("안녕하세요")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messageId").isNumber()) + .andExpect(jsonPath("$.senderId").value(normalUser.getId())) + .andExpect(jsonPath("$.senderName").doesNotExist()) + .andExpect(jsonPath("$.content").value("안녕하세요")) + .andExpect(jsonPath("$.isRead").value(true)) + .andExpect(jsonPath("$.unreadCount").isNumber()) + .andExpect(jsonPath("$.isMine").value(true)); + + clearPersistenceContext(); + assertThat(chatMessageRepository.findByChatRoomId(chatRoom.getId(), PageRequest.of(0, 20)).getContent()) + .hasSize(1) + .extracting(ChatMessage::getContent) + .containsExactly("안녕하세요"); + } + + @Test + @DisplayName("빈 메시지를 전송하면 400을 반환한다") + void sendBlankMessageFails() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest(" ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("content")); + } + + @Test + @DisplayName("1000자를 초과한 메시지를 전송하면 400을 반환한다") + void sendTooLongMessageFails() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + String tooLongContent = "a".repeat(1001); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest(tooLongContent)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("content")); + } + } + + @Nested + @DisplayName("GET /chats/rooms/{chatRoomId} - 채팅방 메시지 조회 실패") + class GetMessagesFail { + + @BeforeEach + void setUpMessageAccessFixture() { + targetUser = createUser("상대유저", "2021136002"); + outsiderUser = createUser("외부유저", "2021136003"); + clearPersistenceContext(); + } + + @Test + @DisplayName("존재하지 않는 채팅방이면 404를 반환한다") + void getMessagesNotFound() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/99999?page=1&limit=20") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_CHAT_ROOM")); + } + + @Test + @DisplayName("참여하지 않은 사용자가 조회하면 403을 반환한다") + void getMessagesForbidden() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(outsiderUser.getId()); + + // when & then + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/mute - 채팅방 뮤트 토글") + class ToggleMute { + + @BeforeEach + void setUpMuteFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("뮤트를 켰다가 다시 끈다") + void toggleMuteSuccessAndDuplicateProcessing() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/mute") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isMuted").value(true)); + + performPost("/chats/rooms/" + chatRoom.getId() + "/mute") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isMuted").value(false)); + + clearPersistenceContext(); + assertThat(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + chatRoom.getId(), + normalUser.getId() + )).matches(setting -> setting.isEmpty() || !setting.get().getIsMuted()); + } + } + + private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { + ChatRoom chatRoom = persist(ChatRoom.directOf()); + LocalDateTime joinedAt = chatRoom.getCreatedAt(); + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedFirstUser = entityManager.getReference(User.class, firstUser.getId()); + User managedSecondUser = entityManager.getReference(User.class, secondUser.getId()); + + persist(ChatRoomMember.of(managedChatRoom, managedFirstUser, joinedAt)); + persist(ChatRoomMember.of(managedChatRoom, managedSecondUser, joinedAt)); + clearPersistenceContext(); + return chatRoom; + } + + private User createUser(String name, String studentId) { + return persist(UserFixture.createUser(university, name, studentId)); + } + + private long countDirectRoomsBetween(User firstUser, User secondUser) { + return chatRoomRepository.findByUserId(firstUser.getId()).stream() + .map(ChatRoom::getId) + .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) + .count(); + } + + private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer secondUserId) { + List roomMembers = chatRoomMemberRepository.findByChatRoomId(roomId); + return roomMembers.size() == 2 + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(firstUserId)) + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); + } +} From 00ba4e205e2a566c0cbd3851238401badd8d0aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:08:11 +0900 Subject: [PATCH 03/55] =?UTF-8?q?feat:=20=EA=B4=91=EA=B3=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=20=EC=B6=94=EA=B0=80=20(#410)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 광고 테이블 추가 마이그레이션 * feat: 광고 엔티티 추가 * feat: 어드민 광고 CRUD API 추가 * feat: 광고 조회 및 클릭 집계 API 추가 * feat: 광고 API 통합 테스트 추가 * chore: 코드 포맷팅 * refactor: 롬복의 게터 적용 * feat: 사이즈 검증 어노테이션 추가 * refactor: 클릭 집계 원자적으로 증가 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * fix: 광고 클릭 수 동시성 및 비노출 광고 클릭 방지 * feat: 수정 요청 DTO에 사이즈 검증 어노테이션 추가 * refactor: FQN 제거 * fix: increaseClickCount() 메서드 제거 * fix: null-safe 검증 * chore: 코드 포맷팅 --- .../controller/AdminAdvertisementApi.java | 49 +++++ .../AdminAdvertisementController.java | 58 ++++++ .../dto/AdminAdvertisementCreateRequest.java | 35 ++++ .../dto/AdminAdvertisementResponse.java | 55 ++++++ .../dto/AdminAdvertisementUpdateRequest.java | 35 ++++ .../dto/AdminAdvertisementsResponse.java | 21 +++ .../service/AdminAdvertisementService.java | 61 ++++++ .../controller/AdvertisementApi.java | 33 ++++ .../controller/AdvertisementController.java | 39 ++++ .../dto/AdvertisementResponse.java | 33 ++++ .../dto/AdvertisementsResponse.java | 21 +++ .../advertisement/model/Advertisement.java | 90 +++++++++ .../repository/AdvertisementRepository.java | 42 +++++ .../service/AdvertisementService.java | 45 +++++ .../konect/global/code/ApiResponseCode.java | 1 + .../V50__add_advertisement_table.sql | 12 ++ .../advertisement/AdvertisementApiTest.java | 176 ++++++++++++++++++ .../support/fixture/AdvertisementFixture.java | 16 ++ src/test/resources/.env.test.properties | 35 ++++ 19 files changed, 857 insertions(+) create mode 100644 src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java create mode 100644 src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java create mode 100644 src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementCreateRequest.java create mode 100644 src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementResponse.java create mode 100644 src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementUpdateRequest.java create mode 100644 src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementsResponse.java create mode 100644 src/main/java/gg/agit/konect/admin/advertisement/service/AdminAdvertisementService.java create mode 100644 src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java create mode 100644 src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java create mode 100644 src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementsResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/advertisement/model/Advertisement.java create mode 100644 src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java create mode 100644 src/main/resources/db/migration/V50__add_advertisement_table.sql create mode 100644 src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java create mode 100644 src/test/java/gg/agit/konect/support/fixture/AdvertisementFixture.java create mode 100644 src/test/resources/.env.test.properties diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java new file mode 100644 index 00000000..229e26d3 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java @@ -0,0 +1,49 @@ +package gg.agit.konect.admin.advertisement.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Advertisement: 광고", description = "어드민 광고 API") +@RequestMapping("/admin/advertisements") +@Auth(roles = {UserRole.ADMIN}) +public interface AdminAdvertisementApi { + + @Operation(summary = "광고 목록을 조회한다.") + @GetMapping + ResponseEntity getAdvertisements(); + + @Operation(summary = "광고 단건을 조회한다.") + @GetMapping("/{id}") + ResponseEntity getAdvertisement(@PathVariable Integer id); + + @Operation(summary = "광고를 생성한다.") + @PostMapping + ResponseEntity createAdvertisement(@Valid @RequestBody AdminAdvertisementCreateRequest request); + + @Operation(summary = "광고를 수정한다.") + @PutMapping("/{id}") + ResponseEntity updateAdvertisement( + @PathVariable Integer id, + @Valid @RequestBody AdminAdvertisementUpdateRequest request + ); + + @Operation(summary = "광고를 삭제한다.") + @DeleteMapping("/{id}") + ResponseEntity deleteAdvertisement(@PathVariable Integer id); +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java new file mode 100644 index 00000000..85ba7cb6 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java @@ -0,0 +1,58 @@ +package gg.agit.konect.admin.advertisement.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; +import gg.agit.konect.admin.advertisement.service.AdminAdvertisementService; +import jakarta.validation.Valid; + +@RestController +@Validated +public class AdminAdvertisementController implements AdminAdvertisementApi { + + private final AdminAdvertisementService adminAdvertisementService; + + public AdminAdvertisementController(AdminAdvertisementService adminAdvertisementService) { + this.adminAdvertisementService = adminAdvertisementService; + } + + @Override + public ResponseEntity getAdvertisements() { + AdminAdvertisementsResponse response = adminAdvertisementService.getAdvertisements(); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getAdvertisement(@PathVariable Integer id) { + AdminAdvertisementResponse response = adminAdvertisementService.getAdvertisement(id); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity createAdvertisement(@Valid @RequestBody AdminAdvertisementCreateRequest request) { + adminAdvertisementService.createAdvertisement(request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity updateAdvertisement( + @PathVariable Integer id, + @Valid @RequestBody AdminAdvertisementUpdateRequest request + ) { + adminAdvertisementService.updateAdvertisement(id, request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity deleteAdvertisement(@PathVariable Integer id) { + adminAdvertisementService.deleteAdvertisement(id); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementCreateRequest.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementCreateRequest.java new file mode 100644 index 00000000..83f1625b --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementCreateRequest.java @@ -0,0 +1,35 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminAdvertisementCreateRequest( + @NotBlank(message = "광고 제목은 필수 입력입니다.") + @Size(max = 100, message = "광고 제목은 100자를 초과할 수 없습니다.") + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @NotBlank(message = "광고 설명은 필수 입력입니다.") + @Size(max = 255, message = "광고 설명은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @NotBlank(message = "광고 이미지는 필수 입력입니다.") + @Size(max = 255, message = "광고 이미지 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @NotBlank(message = "광고 링크는 필수 입력입니다.") + @Size(max = 255, message = "광고 링크 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl, + + @NotNull(message = "광고 노출 여부는 필수 입력입니다.") + @Schema(description = "광고 노출 여부", example = "true", requiredMode = REQUIRED) + Boolean isVisible +) { +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementResponse.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementResponse.java new file mode 100644 index 00000000..238bba9d --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementResponse.java @@ -0,0 +1,55 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminAdvertisementResponse( + @Schema(description = "광고 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl, + + @Schema(description = "광고 노출 여부", example = "true", requiredMode = REQUIRED) + Boolean isVisible, + + @Schema(description = "광고 클릭 수", example = "3", requiredMode = REQUIRED) + Integer clickCount, + + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + @Schema(description = "생성 일시", example = "2026.03.18 14:00", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + @Schema(description = "수정 일시", example = "2026.03.18 14:00", requiredMode = REQUIRED) + LocalDateTime updatedAt +) { + public static AdminAdvertisementResponse from(Advertisement advertisement) { + return new AdminAdvertisementResponse( + advertisement.getId(), + advertisement.getTitle(), + advertisement.getDescription(), + advertisement.getImageUrl(), + advertisement.getLinkUrl(), + advertisement.getIsVisible(), + advertisement.getClickCount(), + advertisement.getCreatedAt(), + advertisement.getUpdatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementUpdateRequest.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementUpdateRequest.java new file mode 100644 index 00000000..c502f3c7 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementUpdateRequest.java @@ -0,0 +1,35 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminAdvertisementUpdateRequest( + @NotBlank(message = "광고 제목은 필수 입력입니다.") + @Size(max = 100, message = "광고 제목은 100자를 초과할 수 없습니다.") + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @NotBlank(message = "광고 설명은 필수 입력입니다.") + @Size(max = 255, message = "광고 설명은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @NotBlank(message = "광고 이미지는 필수 입력입니다.") + @Size(max = 255, message = "광고 이미지 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @NotBlank(message = "광고 링크는 필수 입력입니다.") + @Size(max = 255, message = "광고 링크 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl, + + @NotNull(message = "광고 노출 여부는 필수 입력입니다.") + @Schema(description = "광고 노출 여부", example = "true", requiredMode = REQUIRED) + Boolean isVisible +) { +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementsResponse.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementsResponse.java new file mode 100644 index 00000000..318a469e --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementsResponse.java @@ -0,0 +1,21 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminAdvertisementsResponse( + @Schema(description = "광고 리스트", requiredMode = REQUIRED) + List advertisements +) { + public static AdminAdvertisementsResponse from(List advertisements) { + return new AdminAdvertisementsResponse( + advertisements.stream() + .map(AdminAdvertisementResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/service/AdminAdvertisementService.java b/src/main/java/gg/agit/konect/admin/advertisement/service/AdminAdvertisementService.java new file mode 100644 index 00000000..9791643d --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/service/AdminAdvertisementService.java @@ -0,0 +1,61 @@ +package gg.agit.konect.admin.advertisement.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.domain.advertisement.repository.AdvertisementRepository; + +@Service +@Transactional(readOnly = true) +public class AdminAdvertisementService { + + private final AdvertisementRepository advertisementRepository; + + public AdminAdvertisementService(AdvertisementRepository advertisementRepository) { + this.advertisementRepository = advertisementRepository; + } + + public AdminAdvertisementsResponse getAdvertisements() { + return AdminAdvertisementsResponse.from(advertisementRepository.findAllByOrderByCreatedAtDesc()); + } + + public AdminAdvertisementResponse getAdvertisement(Integer id) { + Advertisement advertisement = advertisementRepository.getById(id); + return AdminAdvertisementResponse.from(advertisement); + } + + @Transactional + public void createAdvertisement(AdminAdvertisementCreateRequest request) { + Advertisement advertisement = Advertisement.of( + request.title(), + request.description(), + request.imageUrl(), + request.linkUrl(), + request.isVisible() + ); + advertisementRepository.save(advertisement); + } + + @Transactional + public void updateAdvertisement(Integer id, AdminAdvertisementUpdateRequest request) { + Advertisement advertisement = advertisementRepository.getById(id); + advertisement.update( + request.title(), + request.description(), + request.imageUrl(), + request.linkUrl(), + request.isVisible() + ); + } + + @Transactional + public void deleteAdvertisement(Integer id) { + Advertisement advertisement = advertisementRepository.getById(id); + advertisementRepository.delete(advertisement); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java new file mode 100644 index 00000000..d4b53afb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java @@ -0,0 +1,33 @@ +package gg.agit.konect.domain.advertisement.controller; + +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; +import gg.agit.konect.global.auth.annotation.PublicApi; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Advertisement: 광고", description = "광고 API") +@RequestMapping("/advertisements") +public interface AdvertisementApi { + + @PublicApi + @Operation(summary = "노출 가능한 광고 목록을 조회한다.") + @GetMapping + ResponseEntity getAdvertisements(); + + @PublicApi + @Operation(summary = "노출 가능한 광고 단건을 조회한다.") + @GetMapping("/{id}") + ResponseEntity getAdvertisement(@PathVariable Integer id); + + @PublicApi + @Operation(summary = "광고 클릭 수를 증가시킨다.") + @PostMapping("/{id}/clicks") + ResponseEntity increaseClickCount(@PathVariable Integer id); +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java new file mode 100644 index 00000000..de8f6d89 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java @@ -0,0 +1,39 @@ +package gg.agit.konect.domain.advertisement.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; +import gg.agit.konect.domain.advertisement.service.AdvertisementService; + +@RestController +@Validated +public class AdvertisementController implements AdvertisementApi { + + private final AdvertisementService advertisementService; + + public AdvertisementController(AdvertisementService advertisementService) { + this.advertisementService = advertisementService; + } + + @Override + public ResponseEntity getAdvertisements() { + AdvertisementsResponse response = advertisementService.getVisibleAdvertisements(); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getAdvertisement(@PathVariable Integer id) { + AdvertisementResponse response = advertisementService.getVisibleAdvertisement(id); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity increaseClickCount(@PathVariable Integer id) { + advertisementService.increaseClickCount(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementResponse.java b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementResponse.java new file mode 100644 index 00000000..ae1fa473 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementResponse.java @@ -0,0 +1,33 @@ +package gg.agit.konect.domain.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdvertisementResponse( + @Schema(description = "광고 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl +) { + public static AdvertisementResponse from(Advertisement advertisement) { + return new AdvertisementResponse( + advertisement.getId(), + advertisement.getTitle(), + advertisement.getDescription(), + advertisement.getImageUrl(), + advertisement.getLinkUrl() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementsResponse.java b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementsResponse.java new file mode 100644 index 00000000..206b6188 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementsResponse.java @@ -0,0 +1,21 @@ +package gg.agit.konect.domain.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdvertisementsResponse( + @Schema(description = "광고 리스트", requiredMode = REQUIRED) + List advertisements +) { + public static AdvertisementsResponse from(List advertisements) { + return new AdvertisementsResponse( + advertisements.stream() + .map(AdvertisementResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/model/Advertisement.java b/src/main/java/gg/agit/konect/domain/advertisement/model/Advertisement.java new file mode 100644 index 00000000..ab742c8d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/model/Advertisement.java @@ -0,0 +1,90 @@ +package gg.agit.konect.domain.advertisement.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "advertisement") +@NoArgsConstructor(access = PROTECTED) +public class Advertisement extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @Column(name = "title", length = 100, nullable = false) + private String title; + + @Column(name = "description", length = 255, nullable = false) + private String description; + + @Column(name = "image_url", length = 255, nullable = false) + private String imageUrl; + + @Column(name = "link_url", length = 255, nullable = false) + private String linkUrl; + + @Column(name = "is_visible", nullable = false) + private Boolean isVisible; + + @Column(name = "click_count", nullable = false) + private Integer clickCount; + + private Advertisement( + String title, + String description, + String imageUrl, + String linkUrl, + Boolean isVisible, + Integer clickCount + ) { + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.isVisible = isVisible; + this.clickCount = clickCount; + } + + public static Advertisement of( + String title, + String description, + String imageUrl, + String linkUrl, + Boolean isVisible + ) { + return new Advertisement( + title, + description, + imageUrl, + linkUrl, + isVisible, + 0 + ); + } + + public void update( + String title, + String description, + String imageUrl, + String linkUrl, + Boolean isVisible + ) { + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.isVisible = isVisible; + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java new file mode 100644 index 00000000..46de3b53 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.advertisement.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public interface AdvertisementRepository extends Repository { + + Advertisement save(Advertisement advertisement); + + Optional findById(Integer id); + + List findAllByOrderByCreatedAtDesc(); + + List findAllByIsVisibleTrueOrderByCreatedAtDesc(); + + void delete(Advertisement advertisement); + + /** + * 노출 중인 광고의 클릭 수를 원자적으로 증가시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 UPDATE ... SET click_count = click_count + 1을 수행합니다. + * isVisible=true인 광고만 클릭 수를 증가시키며, 해당하는 광고가 없으면 0을 반환합니다. + * + * @return 업데이트된 행 수 (0이면 노출 중인 광고가 없음) + */ + @Modifying + @Query("UPDATE Advertisement a SET a.clickCount = a.clickCount + 1 WHERE a.id = :id AND a.isVisible = true") + int incrementClickCount(@Param("id") Integer id); + + default Advertisement getById(Integer id) { + return findById(id) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_ADVERTISEMENT)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java new file mode 100644 index 00000000..d865338e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.advertisement.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_ADVERTISEMENT; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.domain.advertisement.repository.AdvertisementRepository; +import gg.agit.konect.global.exception.CustomException; + +@Service +@Transactional(readOnly = true) +public class AdvertisementService { + + private final AdvertisementRepository advertisementRepository; + + public AdvertisementService(AdvertisementRepository advertisementRepository) { + this.advertisementRepository = advertisementRepository; + } + + public AdvertisementsResponse getVisibleAdvertisements() { + return AdvertisementsResponse.from(advertisementRepository.findAllByIsVisibleTrueOrderByCreatedAtDesc()); + } + + public AdvertisementResponse getVisibleAdvertisement(Integer id) { + Advertisement advertisement = advertisementRepository.getById(id); + + if (!Boolean.TRUE.equals(advertisement.getIsVisible())) { + throw CustomException.of(NOT_FOUND_ADVERTISEMENT); + } + + return AdvertisementResponse.from(advertisement); + } + + @Transactional + public void increaseClickCount(Integer id) { + int updatedCount = advertisementRepository.incrementClickCount(id); + if (updatedCount == 0) { + throw CustomException.of(NOT_FOUND_ADVERTISEMENT); + } + } +} diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index ed6b7ab4..599ba0d3 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -91,6 +91,7 @@ public enum ApiResponseCode { NOT_FOUND_BANK(HttpStatus.NOT_FOUND, "해당하는 은행을 찾을 수 없습니다."), NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), + NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), diff --git a/src/main/resources/db/migration/V50__add_advertisement_table.sql b/src/main/resources/db/migration/V50__add_advertisement_table.sql new file mode 100644 index 00000000..9b2c47a3 --- /dev/null +++ b/src/main/resources/db/migration/V50__add_advertisement_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS advertisement +( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(100) NOT NULL, + description VARCHAR(255) NOT NULL, + image_url VARCHAR(255) NOT NULL, + link_url VARCHAR(255) NOT NULL, + is_visible TINYINT(1) NOT NULL DEFAULT 1, + click_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java b/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java new file mode 100644 index 00000000..e35f6f51 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java @@ -0,0 +1,176 @@ +package gg.agit.konect.integration.domain.advertisement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.AdvertisementFixture; + +class AdvertisementApiTest extends IntegrationTestSupport { + + @Nested + @DisplayName("GET /advertisements - 광고 목록 조회") + class GetAdvertisements { + + @Test + @DisplayName("노출 가능한 광고만 조회한다") + void getVisibleAdvertisements() throws Exception { + // given + persist(AdvertisementFixture.create("노출 광고", true)); + persist(AdvertisementFixture.create("비노출 광고", false)); + clearPersistenceContext(); + + // when & then + performGet("/advertisements") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(1))) + .andExpect(jsonPath("$.advertisements[0].title").value("노출 광고")); + } + } + + @Nested + @DisplayName("GET /advertisements/{id} - 광고 단건 조회") + class GetAdvertisement { + + @Test + @DisplayName("노출 가능한 광고 단건을 조회한다") + void getVisibleAdvertisement() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("단건 광고", true)); + clearPersistenceContext(); + + // when & then + performGet("/advertisements/" + advertisement.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("단건 광고")); + } + + @Test + @DisplayName("비노출 광고 단건 조회 시 404를 반환한다") + void getHiddenAdvertisement() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("숨김 광고", false)); + clearPersistenceContext(); + + // when & then + performGet("/advertisements/" + advertisement.getId()) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("POST /advertisements/{id}/clicks - 광고 클릭 수 증가") + class IncreaseClickCount { + + @Test + @DisplayName("광고 클릭 수를 1 증가시킨다") + void increaseClickCount() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("클릭 광고", true)); + clearPersistenceContext(); + + // when & then + performPost("/advertisements/" + advertisement.getId() + "/clicks") + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + Advertisement foundAdvertisement = entityManager.find(Advertisement.class, advertisement.getId()); + assertThat(foundAdvertisement.getClickCount()).isEqualTo(1); + } + } + + @Nested + @DisplayName("Admin Advertisement API") + class AdminAdvertisementCrud { + + @Test + @DisplayName("어드민이 광고를 생성한다") + void createAdvertisement() throws Exception { + // given + AdminAdvertisementCreateRequest request = new AdminAdvertisementCreateRequest( + "생성 광고", + "생성 설명", + "https://example.com/create.png", + "https://example.com/create", + true + ); + + // when & then + performPost("/admin/advertisements", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + Long count = entityManager.createQuery("select count(a) from Advertisement a", Long.class) + .getSingleResult(); + assertThat(count).isEqualTo(1L); + } + + @Test + @DisplayName("어드민이 광고 목록과 단건을 조회한다") + void getAdvertisementsAndDetail() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("관리 광고", true)); + clearPersistenceContext(); + + // when & then + performGet("/admin/advertisements") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(1))) + .andExpect(jsonPath("$.advertisements[0].title").value("관리 광고")); + + performGet("/admin/advertisements/" + advertisement.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("관리 광고")); + } + + @Test + @DisplayName("어드민이 광고를 수정한다") + void updateAdvertisement() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("수정 전 광고", true)); + clearPersistenceContext(); + + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 후 광고", + "수정 설명", + "https://example.com/update.png", + "https://example.com/update", + false + ); + + // when & then + performPut("/admin/advertisements/" + advertisement.getId(), request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + Advertisement foundAdvertisement = entityManager.find(Advertisement.class, advertisement.getId()); + assertThat(foundAdvertisement.getTitle()).isEqualTo("수정 후 광고"); + assertThat(foundAdvertisement.getIsVisible()).isFalse(); + } + + @Test + @DisplayName("어드민이 광고를 삭제한다") + void deleteAdvertisement() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("삭제 광고", true)); + clearPersistenceContext(); + + // when & then + performDelete("/admin/advertisements/" + advertisement.getId()) + .andExpect(status().isOk()); + + clearPersistenceContext(); + Advertisement foundAdvertisement = entityManager.find(Advertisement.class, advertisement.getId()); + assertThat(foundAdvertisement).isNull(); + } + } +} diff --git a/src/test/java/gg/agit/konect/support/fixture/AdvertisementFixture.java b/src/test/java/gg/agit/konect/support/fixture/AdvertisementFixture.java new file mode 100644 index 00000000..24f4d065 --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/AdvertisementFixture.java @@ -0,0 +1,16 @@ +package gg.agit.konect.support.fixture; + +import gg.agit.konect.domain.advertisement.model.Advertisement; + +public class AdvertisementFixture { + + public static Advertisement create(String title, boolean isVisible) { + return Advertisement.of( + title, + title + " 설명", + "https://example.com/advertisement.png", + "https://example.com", + isVisible + ); + } +} diff --git a/src/test/resources/.env.test.properties b/src/test/resources/.env.test.properties new file mode 100644 index 00000000..d1c4a7b9 --- /dev/null +++ b/src/test/resources/.env.test.properties @@ -0,0 +1,35 @@ +# Test-only placeholder for @TestPropertySource. +ALLOWED_ORIGINS=http://localhost:3000 +APP_JWT_SECRET=test-secret-key-for-testing-purposes-only-32-chars +APP_JWT_ISSUER=test-issuer +APP_COOKIE_DOMAIN=localhost +APP_FRONTEND_BASE_URL=http://localhost:3000 +APP_BACKEND_BASE_URL=http://localhost:8080 +SESSION_COOKIE_DOMAIN=localhost + +MYSQL_URL=jdbc:h2:mem:test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +MYSQL_USERNAME=sa +MYSQL_PASSWORD= +REDIS_HOST=localhost +REDIS_PORT=6379 + +OAUTH_GOOGLE_CLIENT_ID=test-google-client-id +OAUTH_GOOGLE_CLIENT_SECRET=test-google-client-secret +OAUTH_NAVER_CLIENT_ID=test-naver-client-id +OAUTH_NAVER_CLIENT_SECRET=test-naver-client-secret +OAUTH_KAKAO_CLIENT_ID=test-kakao-client-id +OAUTH_KAKAO_CLIENT_SECRET=test-kakao-client-secret +OAUTH_APPLE_TEAM_ID=test-apple-team-id +OAUTH_APPLE_CLIENT_ID=test-apple-client-id +OAUTH_APPLE_CLIENT_SECRET=test-apple-client-secret +OAUTH_APPLE_KEY_ID=test-apple-key-id +OAUTH_APPLE_PRIVATE_KEY_PATH=classpath:test-key.p8 + +STORAGE_S3_BUCKET=test-bucket +STORAGE_S3_REGION=ap-northeast-2 +STORAGE_CDN_BASE_URL=https://cdn.test.com +SLACK_WEBHOOK_ERROR=https://hooks.slack.com/test +SLACK_WEBHOOK_EVENT=https://hooks.slack.com/test-event +SLACK_SIGNING_SECRET=test-signing-secret +SLACK_BOT_TOKEN=test-slack-bot-token +CLAUDE_API_KEY=test-api-key From deedd93a5fcb6c2740f2b6f4985abf589ba836c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:39:44 +0900 Subject: [PATCH 04/55] =?UTF-8?q?fix:=20=EA=B4=91=EA=B3=A0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=ED=86=B5=ED=95=A9=20(#417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 광고 조회 API 통합 * test: 수정된 API에 대한 테스트 코드 작성 * refactor: 전체 광고 엔티티 로딩 제거 * test: 중복 없음 검증 추가 * chore: 코드 포맷팅 --- .../controller/AdvertisementApi.java | 17 +-- .../controller/AdvertisementController.java | 11 +- .../repository/AdvertisementRepository.java | 3 + .../service/AdvertisementService.java | 33 ++++-- .../advertisement/AdvertisementApiTest.java | 103 ++++++++++++++---- 5 files changed, 119 insertions(+), 48 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java index d4b53afb..04051d03 100644 --- a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java +++ b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java @@ -5,26 +5,27 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; -import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; import gg.agit.konect.global.auth.annotation.PublicApi; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; @Tag(name = "(Normal) Advertisement: 광고", description = "광고 API") @RequestMapping("/advertisements") public interface AdvertisementApi { @PublicApi - @Operation(summary = "노출 가능한 광고 목록을 조회한다.") + @Operation(summary = "노출 가능한 광고를 랜덤으로 조회한다. 필요로 하는 수가 더 큰 경우 중복 허용.") @GetMapping - ResponseEntity getAdvertisements(); - - @PublicApi - @Operation(summary = "노출 가능한 광고 단건을 조회한다.") - @GetMapping("/{id}") - ResponseEntity getAdvertisement(@PathVariable Integer id); + ResponseEntity getAdvertisements( + @Parameter(description = "조회할 광고 개수 (1~10)", example = "1") + @RequestParam(defaultValue = "1") @Min(1) @Max(10) int count + ); @PublicApi @Operation(summary = "광고 클릭 수를 증가시킨다.") diff --git a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java index de8f6d89..09d6f7a0 100644 --- a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java +++ b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java @@ -5,7 +5,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; -import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; import gg.agit.konect.domain.advertisement.service.AdvertisementService; @@ -20,14 +19,8 @@ public AdvertisementController(AdvertisementService advertisementService) { } @Override - public ResponseEntity getAdvertisements() { - AdvertisementsResponse response = advertisementService.getVisibleAdvertisements(); - return ResponseEntity.ok(response); - } - - @Override - public ResponseEntity getAdvertisement(@PathVariable Integer id) { - AdvertisementResponse response = advertisementService.getVisibleAdvertisement(id); + public ResponseEntity getAdvertisements(int count) { + AdvertisementsResponse response = advertisementService.getRandomAdvertisements(count); return ResponseEntity.ok(response); } diff --git a/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java index 46de3b53..d88ee483 100644 --- a/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java +++ b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java @@ -22,6 +22,9 @@ public interface AdvertisementRepository extends Repository findAllByIsVisibleTrueOrderByCreatedAtDesc(); + @Query("SELECT a.id FROM Advertisement a WHERE a.isVisible = true") + List findAllVisibleIds(); + void delete(Advertisement advertisement); /** diff --git a/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java index d865338e..7328a2da 100644 --- a/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java +++ b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java @@ -2,10 +2,13 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_ADVERTISEMENT; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; import gg.agit.konect.domain.advertisement.model.Advertisement; import gg.agit.konect.domain.advertisement.repository.AdvertisementRepository; @@ -21,18 +24,30 @@ public AdvertisementService(AdvertisementRepository advertisementRepository) { this.advertisementRepository = advertisementRepository; } - public AdvertisementsResponse getVisibleAdvertisements() { - return AdvertisementsResponse.from(advertisementRepository.findAllByIsVisibleTrueOrderByCreatedAtDesc()); - } + public AdvertisementsResponse getRandomAdvertisements(int count) { + List visibleIds = advertisementRepository.findAllVisibleIds(); + + if (visibleIds.isEmpty()) { + return AdvertisementsResponse.from(List.of()); + } - public AdvertisementResponse getVisibleAdvertisement(Integer id) { - Advertisement advertisement = advertisementRepository.getById(id); + List selectedIds = new ArrayList<>(); - if (!Boolean.TRUE.equals(advertisement.getIsVisible())) { - throw CustomException.of(NOT_FOUND_ADVERTISEMENT); + if (visibleIds.size() >= count) { + Collections.shuffle(visibleIds); + selectedIds.addAll(visibleIds.subList(0, count)); + } else { + for (int i = 0; i < count; i++) { + int randomIndex = (int)(Math.random() * visibleIds.size()); + selectedIds.add(visibleIds.get(randomIndex)); + } } - return AdvertisementResponse.from(advertisement); + List result = selectedIds.stream() + .map(advertisementRepository::getById) + .toList(); + + return AdvertisementsResponse.from(result); } @Transactional diff --git a/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java b/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java index e35f6f51..735a15ad 100644 --- a/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java @@ -5,12 +5,17 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.List; +import java.util.Set; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; import gg.agit.konect.domain.advertisement.model.Advertisement; import gg.agit.konect.support.IntegrationTestSupport; import gg.agit.konect.support.fixture.AdvertisementFixture; @@ -18,52 +23,106 @@ class AdvertisementApiTest extends IntegrationTestSupport { @Nested - @DisplayName("GET /advertisements - 광고 목록 조회") + @DisplayName("GET /advertisements - 랜덤 광고 목록 조회") class GetAdvertisements { @Test - @DisplayName("노출 가능한 광고만 조회한다") - void getVisibleAdvertisements() throws Exception { + @DisplayName("노출 가능한 광고를 랜덤으로 count개 조회한다 - 비노출 광고는 제외") + void getRandomAdvertisements() throws Exception { // given - persist(AdvertisementFixture.create("노출 광고", true)); + persist(AdvertisementFixture.create("광고1", true)); + persist(AdvertisementFixture.create("광고2", true)); + persist(AdvertisementFixture.create("광고3", true)); persist(AdvertisementFixture.create("비노출 광고", false)); clearPersistenceContext(); - // when & then - performGet("/advertisements") + // when & then - 반환된 광고는 모두 노출 광고만 포함 + performGet("/advertisements?count=2") .andExpect(status().isOk()) - .andExpect(jsonPath("$.advertisements", hasSize(1))) - .andExpect(jsonPath("$.advertisements[0].title").value("노출 광고")); + .andExpect(jsonPath("$.advertisements", hasSize(2))) + .andExpect(jsonPath("$.advertisements[*].title").value(org.hamcrest.Matchers.not("비노출 광고"))); } - } - @Nested - @DisplayName("GET /advertisements/{id} - 광고 단건 조회") - class GetAdvertisement { + @Test + @DisplayName("count가 visible 광고 수 이하면 중복 없이 반환한다") + void getRandomAdvertisementsNoDuplicate() throws Exception { + // given + persist(AdvertisementFixture.create("광고1", true)); + persist(AdvertisementFixture.create("광고2", true)); + persist(AdvertisementFixture.create("광고3", true)); + clearPersistenceContext(); + + // when + String responseJson = performGet("/advertisements?count=3") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(3))) + .andReturn() + .getResponse() + .getContentAsString(); + + AdvertisementsResponse response = objectMapper.readValue( + responseJson, AdvertisementsResponse.class); + + // then - 반환된 ID는 모두 distinct + List ids = response.advertisements().stream() + .map(AdvertisementResponse::id) + .toList(); + Set uniqueIds = Set.copyOf(ids); + assertThat(uniqueIds).hasSize(3); + } @Test - @DisplayName("노출 가능한 광고 단건을 조회한다") - void getVisibleAdvertisement() throws Exception { + @DisplayName("count 기본값은 1이다") + void getRandomAdvertisementsDefaultCount() throws Exception { // given - Advertisement advertisement = persist(AdvertisementFixture.create("단건 광고", true)); + persist(AdvertisementFixture.create("광고1", true)); clearPersistenceContext(); // when & then - performGet("/advertisements/" + advertisement.getId()) + performGet("/advertisements") .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("단건 광고")); + .andExpect(jsonPath("$.advertisements", hasSize(1))); } @Test - @DisplayName("비노출 광고 단건 조회 시 404를 반환한다") - void getHiddenAdvertisement() throws Exception { + @DisplayName("count가 등록된 광고 수보다 많으면 중복을 허용하여 반환한다") + void getRandomAdvertisementsWithDuplication() throws Exception { + // given - 노출 광고 2개만 등록 + persist(AdvertisementFixture.create("광고1", true)); + persist(AdvertisementFixture.create("광고2", true)); + clearPersistenceContext(); + + // when & then - 5개 요청 시 중복 허용하여 5개 반환 + performGet("/advertisements?count=5") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(5))); + } + + @Test + @DisplayName("count가 1 미만이면 400을 반환한다") + void getRandomAdvertisementsInvalidMinCount() throws Exception { + performGet("/advertisements?count=0") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("count가 10을 초과하면 400을 반환한다") + void getRandomAdvertisementsInvalidMaxCount() throws Exception { + performGet("/advertisements?count=11") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("노출 가능한 광고가 없으면 빈 목록을 반환한다") + void getRandomAdvertisementsEmpty() throws Exception { // given - Advertisement advertisement = persist(AdvertisementFixture.create("숨김 광고", false)); + persist(AdvertisementFixture.create("비노출 광고", false)); clearPersistenceContext(); // when & then - performGet("/advertisements/" + advertisement.getId()) - .andExpect(status().isNotFound()); + performGet("/advertisements?count=3") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(0))); } } From 693e47f1e013448dc9f12bdc39ec98d9f20a7abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:26:13 +0900 Subject: [PATCH 05/55] =?UTF-8?q?test:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20API=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 관리자 일정 API 통합 테스트 작성 * test: 권한 관련 검증 테스트 분리 * chore: 코드 포맷팅 * refactor: 에러 코드 문자열 enum 상수로 통일 * test: 유효하지 않은 배치 요청 이후 DB 값 검증 * chore: FQDN 제거 * fix: 음수 id 테스트에서 에러 코드 검증 추가 * fix: 값 검증 시 영속성 컨텍스트 캐시가 아닌 실제 DB 데이터를 조회 * chore: 코드 포맷팅 * fix: 값 검증 시 영속성 컨텍스트 캐시가 아닌 실제 DB 데이터를 조회 --- .../admin/schedule/AdminScheduleApiTest.java | 757 ++++++++++++++++++ .../auth/AuthorizationInterceptorTest.java | 132 +++ 2 files changed, 889 insertions(+) create mode 100644 src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java create mode 100644 src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java diff --git a/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java new file mode 100644 index 00000000..6ba3b40a --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java @@ -0,0 +1,757 @@ +package gg.agit.konect.integration.admin.schedule; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; +import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertItemRequest; +import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertRequest; +import gg.agit.konect.domain.schedule.model.Schedule; +import gg.agit.konect.domain.schedule.model.ScheduleType; +import gg.agit.konect.domain.schedule.model.UniversitySchedule; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ScheduleFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class AdminScheduleApiTest extends IntegrationTestSupport { + + private static final String BASE_URL = "/admin/schedules"; + + private University university; + private User admin; + + @BeforeEach + void setUp() { + university = persist(UniversityFixture.create()); + admin = persist(UserFixture.createAdmin(university)); + } + + @Nested + @DisplayName("POST /admin/schedules - 일정 생성") + class CreateSchedule { + + @Test + @DisplayName("관리자가 일정 생성에 성공한다") + void createScheduleSuccess() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + // 데이터 저장 검증 + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(1); + assertThat(saved.get(0).getSchedule().getTitle()).isEqualTo("동계방학"); + assertThat(saved.get(0).getSchedule().getScheduleType()).isEqualTo(ScheduleType.UNIVERSITY); + } + + @Test + @DisplayName("제목이 없으면 400 에러를 반환한다") + void createScheduleFailWithoutTitle() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + null, + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("시작일시가 종료일시보다 늦으면 400 에러를 반환한다") + void createScheduleFailWithInvalidDateRange() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(10); + LocalDateTime endedAt = LocalDateTime.now().plusDays(1); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_DATE_TIME.getCode())); + } + + @Test + @DisplayName("일정 종류가 없으면 400 에러를 반환한다") + void createScheduleFailWithoutScheduleType() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + null + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("시작일시가 null이면 400 에러를 반환한다") + void createScheduleFailWithNullStartedAt() throws Exception { + // given + LocalDateTime endedAt = LocalDateTime.now().plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + null, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("종료일시가 null이면 400 에러를 반환한다") + void createScheduleFailWithNullEndedAt() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + null, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("빈 문자열 제목으로는 일정을 생성할 수 없다") + void createScheduleFailWithBlankTitle() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + " ", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("시작일시와 종료일시가 같아도 일정을 생성할 수 있다") + void createScheduleSuccessWithSameStartAndEnd() throws Exception { + // given + LocalDateTime sameDateTime = LocalDateTime.now().plusDays(1); + + var request = new AdminScheduleCreateRequest( + "단일 일정", + sameDateTime, + sameDateTime, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(1); + } + } + + @Nested + @DisplayName("PUT /admin/schedules/batch - 일정 일괄 생성/수정") + class UpsertSchedules { + + @Test + @DisplayName("새로운 일정을 일괄 생성한다") + void upsertSchedulesCreateSuccess() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var item1 = new AdminScheduleUpsertItemRequest( + null, + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + var item2 = new AdminScheduleUpsertItemRequest( + null, + "하계방학", + startedAt.plusMonths(6), + startedAt.plusMonths(6).plusDays(7), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item1, item2)); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(2); + } + + @Test + @DisplayName("기존 일정을 수정한다") + void upsertSchedulesUpdateSuccess() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "기존 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + String newTitle = "수정된 일정"; + LocalDateTime newStartedAt = LocalDateTime.now().plusDays(2); + LocalDateTime newEndedAt = newStartedAt.plusDays(3); + + var item = new AdminScheduleUpsertItemRequest( + universitySchedule.getId(), + newTitle, + newStartedAt, + newEndedAt, + ScheduleType.CLUB + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + UniversitySchedule updated = entityManager.find(UniversitySchedule.class, universitySchedule.getId()); + assertThat(updated.getSchedule().getTitle()).isEqualTo(newTitle); + assertThat(updated.getSchedule().getScheduleType()).isEqualTo(ScheduleType.CLUB); + } + + @Test + @DisplayName("생성과 수정을 동시에 수행한다") + void upsertSchedulesMixedSuccess() throws Exception { + // given + Schedule existingSchedule = persist(ScheduleFixture.createUniversity( + "기존 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule existingUniversitySchedule = persist( + ScheduleFixture.createUniversitySchedule(existingSchedule, university) + ); + clearPersistenceContext(); + + var updateItem = new AdminScheduleUpsertItemRequest( + existingUniversitySchedule.getId(), + "수정된 일정", + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(6), + ScheduleType.UNIVERSITY + ); + + var createItem = new AdminScheduleUpsertItemRequest( + null, + "새 일정", + LocalDateTime.now().plusDays(10), + LocalDateTime.now().plusDays(15), + ScheduleType.DORM + ); + + var request = new AdminScheduleUpsertRequest(List.of(updateItem, createItem)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List allSchedules = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(allSchedules).hasSize(2); + } + + @Test + @DisplayName("빈 목록이면 400 에러를 반환한다") + void upsertSchedulesFailWithEmptyList() throws Exception { + // given + var request = new AdminScheduleUpsertRequest(List.of()); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("수정 시 존재하지 않는 일정 ID면 404 에러를 반환한다") + void upsertSchedulesFailWithNonExistentId() throws Exception { + // given + int nonExistentId = 99999; + + var item = new AdminScheduleUpsertItemRequest( + nonExistentId, + "수정된 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(7), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("다른 대학의 일정은 수정할 수 없다") + void upsertSchedulesFailOtherUniversity() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Schedule otherSchedule = persist(ScheduleFixture.createUniversity( + "다른 대학 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule otherUniversitySchedule = persist( + ScheduleFixture.createUniversitySchedule(otherSchedule, otherUniversity) + ); + clearPersistenceContext(); + + var item = new AdminScheduleUpsertItemRequest( + otherUniversitySchedule.getId(), + "수정 시도", + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(6), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("수정 시 잘못된 날짜 범위면 400 에러를 반환한다") + void upsertSchedulesFailWithInvalidDateRange() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "기존 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + var item = new AdminScheduleUpsertItemRequest( + universitySchedule.getId(), + "수정된 일정", + LocalDateTime.now().plusDays(10), + LocalDateTime.now().plusDays(1), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_DATE_TIME.getCode())); + } + + @Test + @DisplayName("항목 중 하나라도 검증 실패하면 전체 요청이 실패한다") + void upsertSchedulesFailWithOneInvalidItem() throws Exception { + // given + var validItem = new AdminScheduleUpsertItemRequest( + null, + "유효한 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(7), + ScheduleType.UNIVERSITY + ); + + var invalidItem = new AdminScheduleUpsertItemRequest( + null, + "잘못된 일정", + LocalDateTime.now().plusDays(10), + LocalDateTime.now().plusDays(1), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(validItem, invalidItem)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_DATE_TIME.getCode())); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + assertThat(saved).isEmpty(); + } + + @Test + @DisplayName("대량 일정을 일괄 처리할 수 있다") + void upsertSchedulesLargeBatch() throws Exception { + // given + LocalDateTime baseDate = LocalDateTime.now().plusDays(1); + + var items = IntStream.range(0, 50) + .mapToObj(i -> new AdminScheduleUpsertItemRequest( + null, + "일정 " + i, + baseDate.plusDays(i), + baseDate.plusDays(i + 1), + ScheduleType.UNIVERSITY + )) + .toList(); + + var request = new AdminScheduleUpsertRequest(items); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(50); + } + } + + @Nested + @DisplayName("DELETE /admin/schedules/{scheduleId} - 일정 삭제") + class DeleteSchedule { + + @Test + @DisplayName("일정 삭제에 성공한다") + void deleteScheduleSuccess() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "삭제될 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + UniversitySchedule deleted = entityManager.find(UniversitySchedule.class, universitySchedule.getId()); + Schedule deletedSchedule = entityManager.find(Schedule.class, schedule.getId()); + + assertThat(deleted).isNull(); + assertThat(deletedSchedule).isNull(); + } + + @Test + @DisplayName("존재하지 않는 일정이면 404 에러를 반환한다") + void deleteScheduleFailNotFound() throws Exception { + // given + int nonExistentId = 99999; + + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/" + nonExistentId) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("다른 대학의 일정은 삭제할 수 없다") + void deleteScheduleFailOtherUniversity() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Schedule otherSchedule = persist(ScheduleFixture.createUniversity( + "다른 대학 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule otherUniversitySchedule = persist( + ScheduleFixture.createUniversitySchedule(otherSchedule, otherUniversity) + ); + clearPersistenceContext(); + + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/" + otherUniversitySchedule.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("이미 삭제된 일정을 다시 삭제하면 404 에러를 반환한다") + void deleteScheduleFailAlreadyDeleted() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "삭제될 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + mockLoginUser(admin.getId()); + + // when - 첫 삭제 성공 + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + // then - 재삭제 시 404 + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("음수 ID로 삭제 요청하면 404 에러를 반환한다") + void deleteScheduleFailWithNegativeId() throws Exception { + // given + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/-1") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + } + + @Nested + @DisplayName("관리자 권한 검증") + class AdminAuthorization { + + @Test + @DisplayName("일반 사용자는 일정 생성 권한이 없다") + void nonAdminCannotCreateSchedule() throws Exception { + // given + User normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136002")); + clearPersistenceContext(); + + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + + @Test + @DisplayName("일반 사용자는 일정 삭제 권한이 없다") + void nonAdminCannotDeleteSchedule() throws Exception { + // given + User normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136003")); + Schedule schedule = persist(ScheduleFixture.createUniversity( + "일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + + @Test + @DisplayName("일반 사용자는 배치 수정 권한이 없다") + void nonAdminCannotUpsertSchedules() throws Exception { + // given + User normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136004")); + clearPersistenceContext(); + + var item = new AdminScheduleUpsertItemRequest( + null, + "동계방학", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(7), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + } +} diff --git a/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java b/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java new file mode 100644 index 00000000..6d471641 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java @@ -0,0 +1,132 @@ +package gg.agit.konect.unit.auth; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.annotation.AnnotatedElementUtils; + +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.auth.annotation.Auth; +import gg.agit.konect.global.auth.web.AuthorizationInterceptor; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +@ExtendWith(MockitoExtension.class) +class AuthorizationInterceptorTest { + + @Mock + private UserRepository userRepository; + + private AuthorizationInterceptor interceptor; + + @BeforeEach + void setUp() { + interceptor = new AuthorizationInterceptor(userRepository); + } + + @Nested + @DisplayName("validateRole - 권한 판별 로직") + class ValidateRole { + + @Test + @DisplayName("관리자 권한이 있으면 통과한다") + void adminRolePass() throws Exception { + // given + Integer userId = 1; + User admin = createUser(UserRole.ADMIN); + + given(userRepository.getById(userId)).willReturn(admin); + + // when & then - 예외 없이 통과 + invokeValidateRole(userId, adminOnlyAuth()); + } + + @Test + @DisplayName("일반 사용자는 관리자 권한이 필요한 API에 접근할 수 없다") + void userRoleCannotAccessAdminApi() throws Exception { + // given + Integer userId = 1; + User normalUser = createUser(UserRole.USER); + + given(userRepository.getById(userId)).willReturn(normalUser); + + // when & then + assertThatThrownBy(() -> invokeValidateRole(userId, adminOnlyAuth())) + .isInstanceOf(InvocationTargetException.class) + .extracting("cause") + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.FORBIDDEN_ROLE_ACCESS); + } + + @Test + @DisplayName("다중 권한 중 하나라도 일치하면 통과한다") + void oneOfMultipleRolesMatch() throws Exception { + // given + Integer userId = 1; + User admin = createUser(UserRole.ADMIN); + + given(userRepository.getById(userId)).willReturn(admin); + + // when & then - 예외 없이 통과 + invokeValidateRole(userId, multiRoleAuth()); + } + + @Test + @DisplayName("일반 사용자도 USER 권한 API에는 접근할 수 있다") + void userCanAccessUserApi() throws Exception { + // given + Integer userId = 1; + User normalUser = createUser(UserRole.USER); + + given(userRepository.getById(userId)).willReturn(normalUser); + + // when & then - 예외 없이 통과 + invokeValidateRole(userId, multiRoleAuth()); + } + } + + private void invokeValidateRole(Integer userId, Auth auth) throws Exception { + Method method = AuthorizationInterceptor.class.getDeclaredMethod("validateRole", Integer.class, Auth.class); + method.setAccessible(true); + method.invoke(interceptor, userId, auth); + } + + private Auth adminOnlyAuth() throws Exception { + Method method = AuthMethods.class.getDeclaredMethod("adminOnly"); + return AnnotatedElementUtils.findMergedAnnotation(method, Auth.class); + } + + private Auth multiRoleAuth() throws Exception { + Method method = AuthMethods.class.getDeclaredMethod("multiRole"); + return AnnotatedElementUtils.findMergedAnnotation(method, Auth.class); + } + + private User createUser(UserRole role) { + return User.builder() + .role(role) + .build(); + } + + static class AuthMethods { + @Auth(roles = {UserRole.ADMIN}) + void adminOnly() { + } + + @Auth(roles = {UserRole.ADMIN, UserRole.USER}) + void multiRole() { + } + } +} \ No newline at end of file From 223a83cf408a418a2c3f267b6c01b1ef3b923dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:27:11 +0900 Subject: [PATCH 06/55] =?UTF-8?q?test:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1=20(#416)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 동아리 설정 통합 테스트 작성 * chore: 코드 포맷팅 * refactor: 불필요한 엔티티 조회 제거 * fix: 두 번째 요청 전 mockLoginUser 호출 * refactor: 불필요한 인터셉터 스텁 제거 * fix: RecruitmentSummary의 null 필드 직렬화 문제 해결 * test: 후속 GET 요청에 status 코드 검증 추가 * refactor: 존재하지 않는 ID 테스트에 상수 사용 * refactor: 동아리 설정 조회 권한 테스트 중복을 파라미터화로 정리 --- .../domain/club/dto/ClubSettingsResponse.java | 3 + .../club/ClubSettingsControllerTest.java | 295 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java index f532403c..10413e16 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java @@ -6,6 +6,8 @@ import java.time.LocalDateTime; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.swagger.v3.oas.annotations.media.Schema; @@ -30,6 +32,7 @@ public record ClubSettingsResponse( FeeSummary fee ) { @Schema(description = "모집공고 요약") + @JsonInclude(Include.NON_NULL) public record RecruitmentSummary( @Schema(description = "모집 시작일시", example = "2026.02.02 09:00", requiredMode = NOT_REQUIRED) @JsonFormat(pattern = "yyyy.MM.dd HH:mm") diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java new file mode 100644 index 00000000..daf441b1 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java @@ -0,0 +1,295 @@ +package gg.agit.konect.integration.domain.club; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.test.web.servlet.ResultActions; + +import gg.agit.konect.domain.club.dto.ClubSettingsUpdateRequest; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.ClubRecruitmentFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +@DisplayName("ClubSettingsController 통합 테스트") +class ClubSettingsControllerTest extends IntegrationTestSupport { + + private static final long NON_EXISTENT_ID = Long.MAX_VALUE; + + private University university; + private User president; + private User vicePresident; + private User manager; + private User regularMember; + private User nonMember; + private Club club; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + + president = persist(UserFixture.createUser(university, "회장", "2020000001")); + vicePresident = persist(UserFixture.createUser(university, "부회장", "2020000002")); + manager = persist(UserFixture.createUser(university, "운영진", "2020000003")); + regularMember = persist(UserFixture.createUser(university, "일반회원", "2020000004")); + nonMember = persist(UserFixture.createUser(university, "비회원", "2020000005")); + + club = persist(ClubFixture.createWithRecruitment(university, "테스트 동아리")); + + persist(ClubMemberFixture.createPresident(club, president)); + persist(ClubMemberFixture.createVicePresident(club, vicePresident)); + persist(ClubMemberFixture.createManager(club, manager)); + persist(ClubMemberFixture.createMember(club, regularMember)); + + clearPersistenceContext(); + } + + @Nested + @DisplayName("GET /clubs/{clubId}/settings - 동아리 설정 조회") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class GetSettings { + + @ParameterizedTest(name = "{0} 권한으로 설정 조회 시 {2}를 반환한다") + @MethodSource("getSettingsAccessCases") + void getSettingsByRole(String roleName, Integer userId, int expectedStatus, boolean verifyDetailedPayload) + throws Exception { + mockLoginUser(userId); + + ResultActions result = performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().is(expectedStatus)); + + if (expectedStatus == 200) { + result.andExpect(jsonPath("$.isRecruitmentEnabled").value(true)); + } + + if (verifyDetailedPayload) { + assertPresidentSettingsPayload(result); + } + } + + Stream getSettingsAccessCases() { + return Stream.of( + Arguments.of("회장", president.getId(), 200, true), + Arguments.of("부회장", vicePresident.getId(), 200, false), + Arguments.of("운영진", manager.getId(), 200, false), + Arguments.of("일반 회원", regularMember.getId(), 403, false), + Arguments.of("비회원", nonMember.getId(), 403, false) + ); + } + + private void assertPresidentSettingsPayload(ResultActions result) throws Exception { + result + .andExpect(jsonPath("$.isApplicationEnabled").value(true)) + .andExpect(jsonPath("$.isFeeEnabled").value(false)) + .andExpect(jsonPath("$.application").exists()) + .andExpect(jsonPath("$.application.questionCount").isNumber()) + .andExpect(jsonPath("$.fee").doesNotExist()); + } + + @Test + @DisplayName("존재하지 않는 동아리 조회 시 404를 반환한다") + void getSettingsNotFoundClub() throws Exception { + mockLoginUser(president.getId()); + + performGet("/clubs/" + NON_EXISTENT_ID + "/settings") + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("모집공고가 설정된 동아리는 recruitment 필드를 반환한다") + void getSettingsWithRecruitmentInfo() throws Exception { + persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(true)) + .andExpect(jsonPath("$.recruitment").exists()) + .andExpect(jsonPath("$.recruitment.isAlwaysRecruiting").value(true)) + .andExpect(jsonPath("$.recruitment.startAt").doesNotExist()) + .andExpect(jsonPath("$.recruitment.endAt").doesNotExist()); + } + } + + @Nested + @DisplayName("PATCH /clubs/{clubId}/settings - 동아리 설정 수정") + class UpdateSettings { + + @Test + @DisplayName("회장 권한으로 설정 수정에 성공한다") + void updateSettingsAsPresident() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + true + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)) + .andExpect(jsonPath("$.isApplicationEnabled").value(false)) + .andExpect(jsonPath("$.isFeeEnabled").value(true)); + } + + @Test + @DisplayName("부회장 권한으로 설정 수정에 성공한다") + void updateSettingsAsVicePresident() throws Exception { + mockLoginUser(vicePresident.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + true, + false, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isApplicationEnabled").value(false)); + } + + @Test + @DisplayName("운영진 권한으로 설정 수정에 성공한다") + void updateSettingsAsManager() throws Exception { + mockLoginUser(manager.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + true, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)); + } + + @Test + @DisplayName("일부 필드만 수정하면 해당 필드만 변경된다") + void updatePartialSettings() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + null, + null + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)) + .andExpect(jsonPath("$.isApplicationEnabled").value(true)) + .andExpect(jsonPath("$.isFeeEnabled").value(false)); + } + + @Test + @DisplayName("일반 회원은 설정 수정에 실패한다 (403 Forbidden)") + void updateSettingsAsRegularMemberFails() throws Exception { + mockLoginUser(regularMember.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("비회원은 설정 수정에 실패한다 (403 Forbidden)") + void updateSettingsAsNonMemberFails() throws Exception { + mockLoginUser(nonMember.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("존재하지 않는 동아리 수정 시 404를 반환한다") + void updateSettingsNotFoundClub() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + false + ); + + performPatch("/clubs/" + NON_EXISTENT_ID + "/settings", request) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("동일한 설정값으로 수정해도 성공한다") + void updateSettingsWithSameValues() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + true, + true, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(true)) + .andExpect(jsonPath("$.isApplicationEnabled").value(true)) + .andExpect(jsonPath("$.isFeeEnabled").value(false)); + } + } + + @Nested + @DisplayName("권한 경계 테스트") + class PermissionBoundaryTests { + + @Test + @DisplayName("모든 토글을 동시에 변경할 수 있다") + void updateAllTogglesAtOnce() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + true + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + mockLoginUser(president.getId()); + + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)) + .andExpect(jsonPath("$.isApplicationEnabled").value(false)) + .andExpect(jsonPath("$.isFeeEnabled").value(true)); + } + } +} From 29e06b69c41906f4addb8870b4f44540d318e589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:37:40 +0900 Subject: [PATCH 07/55] =?UTF-8?q?hotxfix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=20=EA=B0=9C=EC=84=A0=20(develop)=20(#418)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 동아리 설정 통합 테스트 작성 * chore: 코드 포맷팅 * refactor: 불필요한 엔티티 조회 제거 * fix: 두 번째 요청 전 mockLoginUser 호출 * refactor: 불필요한 인터셉터 스텁 제거 * fix: RecruitmentSummary의 null 필드 직렬화 문제 해결 * test: 후속 GET 요청에 status 코드 검증 추가 * refactor: 존재하지 않는 ID 테스트에 상수 사용 * refactor: 동아리 설정 조회 권한 테스트 중복을 파라미터화로 정리 * hotfix: 채팅방 목록 조회 N+1 문제 해결 * chore: 코드 포맷팅 --- .../repository/ChatMessageRepository.java | 11 +++ .../repository/ChatRoomMemberRepository.java | 11 +++ .../chat/repository/ChatRoomRepository.java | 8 ++ .../domain/chat/service/ChatService.java | 84 +++++++++++++++---- .../club/repository/ClubMemberRepository.java | 1 + 5 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index fab85260..dc7876ac 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -78,6 +78,17 @@ boolean existsUserReplyByRoomId( @Param("adminRole") UserRole adminRole ); + @Query(""" + SELECT DISTINCT cm.chatRoom.id + FROM ChatMessage cm + WHERE cm.chatRoom.id IN :chatRoomIds + AND cm.sender.role != :adminRole + """) + List findRoomIdsWithUserReplyByRoomIds( + @Param("chatRoomIds") List chatRoomIds, + @Param("adminRole") UserRole adminRole + ); + @Query(""" SELECT m FROM ChatMessage m diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 29cc8764..25503085 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -68,6 +68,17 @@ boolean existsByChatRoomIdAndUserId( """) List findByUserId(@Param("userId") Integer userId); + @Query(""" + SELECT crm + FROM ChatRoomMember crm + WHERE crm.id.chatRoomId IN :chatRoomIds + AND crm.id.userId = :userId + """) + List findByChatRoomIdsAndUserId( + @Param("chatRoomIds") List chatRoomIds, + @Param("userId") Integer userId + ); + @Modifying @Query(""" UPDATE ChatRoomMember crm diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index 5051be68..c1a30891 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -54,6 +54,14 @@ AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 """) Optional findByClubId(@Param("clubId") Integer clubId); + @Query(""" + SELECT cr + FROM ChatRoom cr + LEFT JOIN FETCH cr.club c + WHERE c.id IN :clubIds + """) + List findByClubIds(@Param("clubIds") List clubIds); + @Query(""" SELECT DISTINCT cr FROM ChatRoom cr diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 8fc21c25..21f9b463 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -244,7 +245,7 @@ private List getDirectChatRooms(Integer userId) { Map userMap = allUserIds.isEmpty() ? Map.of() : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); + .collect(Collectors.toMap(User::getId, u -> u)); for (ChatRoom chatRoom : personalChatRooms) { List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); @@ -281,8 +282,12 @@ private List getAdminDirectChatRooms() { List adminUserRooms = chatRoomRepository.findAllSystemAdminDirectRooms( SYSTEM_ADMIN_ID, UserRole.ADMIN ); + List roomIds = extractChatRoomIds(adminUserRooms); Map> roomMemberInfoMap = getRoomMemberInfoMap(adminUserRooms); - Map adminUnreadCountMap = getAdminUnreadCountMap(extractChatRoomIds(adminUserRooms)); + Map adminUnreadCountMap = getAdminUnreadCountMap(roomIds); + Set repliedRoomIds = roomIds.isEmpty() + ? Set.of() + : new HashSet<>(chatMessageRepository.findRoomIdsWithUserReplyByRoomIds(roomIds, UserRole.ADMIN)); List allUserIds = roomMemberInfoMap.values().stream() .flatMap(List::stream) @@ -293,7 +298,7 @@ private List getAdminDirectChatRooms() { Map userMap = allUserIds.isEmpty() ? Map.of() : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, user -> user)); + .collect(Collectors.toMap(User::getId, user -> user)); for (ChatRoom chatRoom : adminUserRooms) { List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); @@ -301,7 +306,7 @@ private List getAdminDirectChatRooms() { if (nonAdminUser == null) { continue; } - if (!chatMessageRepository.existsUserReplyByRoomId(chatRoom.getId(), UserRole.ADMIN)) { + if (!repliedRoomIds.contains(chatRoom.getId())) { continue; } @@ -441,17 +446,8 @@ private List getClubChatRooms(Integer userId) { Map membershipByClubId = memberships.stream() .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); - List rooms = memberships.stream() - .map(ClubMember::getClub) - .map(this::resolveOrCreateClubRoom) - .toList(); - - for (ChatRoom room : rooms) { - ClubMember member = membershipByClubId.get(room.getClub().getId()); - if (member != null) { - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); - } - } + List rooms = resolveOrCreateClubRooms(memberships); + ensureClubRoomMembers(rooms, membershipByClubId, userId); List roomIds = rooms.stream().map(ChatRoom::getId).toList(); Map lastMessageMap = getLastMessageMap(roomIds); @@ -596,9 +592,61 @@ private ChatRoom getClubRoom(Integer roomId) { return room; } - private ChatRoom resolveOrCreateClubRoom(Club club) { - return chatRoomRepository.findByClubId(club.getId()) - .orElseGet(() -> chatRoomRepository.save(ChatRoom.groupOf(club))); + private List resolveOrCreateClubRooms(List memberships) { + Map clubById = memberships.stream() + .map(ClubMember::getClub) + .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); + + Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) + .stream() + .filter(room -> room.getClub() != null) + .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); + + for (Map.Entry clubEntry : clubById.entrySet()) { + if (roomByClubId.containsKey(clubEntry.getKey())) { + continue; + } + + ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.groupOf(clubEntry.getValue())); + roomByClubId.put(clubEntry.getKey(), createdRoom); + } + + return memberships.stream() + .map(membership -> roomByClubId.get(membership.getClub().getId())) + .toList(); + } + + private void ensureClubRoomMembers( + List rooms, + Map membershipByClubId, + Integer userId + ) { + if (rooms.isEmpty()) { + return; + } + + Map memberByRoomId = chatRoomMemberRepository + .findByChatRoomIdsAndUserId(extractChatRoomIds(rooms), userId) + .stream() + .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); + + for (ChatRoom room : rooms) { + ClubMember member = membershipByClubId.get(room.getClub().getId()); + if (member == null) { + continue; + } + + ChatRoomMember existingMember = memberByRoomId.get(room.getId()); + if (existingMember != null) { + LocalDateTime lastReadAt = existingMember.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { + existingMember.updateLastReadAt(member.getCreatedAt()); + } + continue; + } + + chatRoomMemberRepository.save(ChatRoomMember.of(room, member.getUser(), member.getCreatedAt())); + } } private List extractChatRoomIds(List chatRooms) { diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 42134e6a..6a08d2a9 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -52,6 +52,7 @@ List findAllByClubIdAndPosition( SELECT cm FROM ClubMember cm JOIN FETCH cm.club c + JOIN FETCH cm.user WHERE cm.id.userId = :userId """) List findAllByUserId(Integer userId); From ba1026a86353ee0f5f779d59df3eff330e3f4de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:38:30 +0900 Subject: [PATCH 08/55] =?UTF-8?q?Revert=20"hotxfix:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=86=8D=EB=8F=84=20=EA=B0=9C=EC=84=A0=20(develop)?= =?UTF-8?q?=20(#418)"=20(#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 29e06b69c41906f4addb8870b4f44540d318e589. --- .../repository/ChatMessageRepository.java | 11 --- .../repository/ChatRoomMemberRepository.java | 11 --- .../chat/repository/ChatRoomRepository.java | 8 -- .../domain/chat/service/ChatService.java | 84 ++++--------------- .../club/repository/ClubMemberRepository.java | 1 - 5 files changed, 18 insertions(+), 97 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index dc7876ac..fab85260 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -78,17 +78,6 @@ boolean existsUserReplyByRoomId( @Param("adminRole") UserRole adminRole ); - @Query(""" - SELECT DISTINCT cm.chatRoom.id - FROM ChatMessage cm - WHERE cm.chatRoom.id IN :chatRoomIds - AND cm.sender.role != :adminRole - """) - List findRoomIdsWithUserReplyByRoomIds( - @Param("chatRoomIds") List chatRoomIds, - @Param("adminRole") UserRole adminRole - ); - @Query(""" SELECT m FROM ChatMessage m diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 25503085..29cc8764 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -68,17 +68,6 @@ boolean existsByChatRoomIdAndUserId( """) List findByUserId(@Param("userId") Integer userId); - @Query(""" - SELECT crm - FROM ChatRoomMember crm - WHERE crm.id.chatRoomId IN :chatRoomIds - AND crm.id.userId = :userId - """) - List findByChatRoomIdsAndUserId( - @Param("chatRoomIds") List chatRoomIds, - @Param("userId") Integer userId - ); - @Modifying @Query(""" UPDATE ChatRoomMember crm diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index c1a30891..5051be68 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -54,14 +54,6 @@ AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 """) Optional findByClubId(@Param("clubId") Integer clubId); - @Query(""" - SELECT cr - FROM ChatRoom cr - LEFT JOIN FETCH cr.club c - WHERE c.id IN :clubIds - """) - List findByClubIds(@Param("clubIds") List clubIds); - @Query(""" SELECT DISTINCT cr FROM ChatRoom cr diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 21f9b463..8fc21c25 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -9,7 +9,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -245,7 +244,7 @@ private List getDirectChatRooms(Integer userId) { Map userMap = allUserIds.isEmpty() ? Map.of() : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); + .collect(Collectors.toMap(User::getId, u -> u)); for (ChatRoom chatRoom : personalChatRooms) { List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); @@ -282,12 +281,8 @@ private List getAdminDirectChatRooms() { List adminUserRooms = chatRoomRepository.findAllSystemAdminDirectRooms( SYSTEM_ADMIN_ID, UserRole.ADMIN ); - List roomIds = extractChatRoomIds(adminUserRooms); Map> roomMemberInfoMap = getRoomMemberInfoMap(adminUserRooms); - Map adminUnreadCountMap = getAdminUnreadCountMap(roomIds); - Set repliedRoomIds = roomIds.isEmpty() - ? Set.of() - : new HashSet<>(chatMessageRepository.findRoomIdsWithUserReplyByRoomIds(roomIds, UserRole.ADMIN)); + Map adminUnreadCountMap = getAdminUnreadCountMap(extractChatRoomIds(adminUserRooms)); List allUserIds = roomMemberInfoMap.values().stream() .flatMap(List::stream) @@ -298,7 +293,7 @@ private List getAdminDirectChatRooms() { Map userMap = allUserIds.isEmpty() ? Map.of() : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, user -> user)); + .collect(Collectors.toMap(User::getId, user -> user)); for (ChatRoom chatRoom : adminUserRooms) { List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); @@ -306,7 +301,7 @@ private List getAdminDirectChatRooms() { if (nonAdminUser == null) { continue; } - if (!repliedRoomIds.contains(chatRoom.getId())) { + if (!chatMessageRepository.existsUserReplyByRoomId(chatRoom.getId(), UserRole.ADMIN)) { continue; } @@ -446,8 +441,17 @@ private List getClubChatRooms(Integer userId) { Map membershipByClubId = memberships.stream() .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); - List rooms = resolveOrCreateClubRooms(memberships); - ensureClubRoomMembers(rooms, membershipByClubId, userId); + List rooms = memberships.stream() + .map(ClubMember::getClub) + .map(this::resolveOrCreateClubRoom) + .toList(); + + for (ChatRoom room : rooms) { + ClubMember member = membershipByClubId.get(room.getClub().getId()); + if (member != null) { + ensureRoomMember(room, member.getUser(), member.getCreatedAt()); + } + } List roomIds = rooms.stream().map(ChatRoom::getId).toList(); Map lastMessageMap = getLastMessageMap(roomIds); @@ -592,61 +596,9 @@ private ChatRoom getClubRoom(Integer roomId) { return room; } - private List resolveOrCreateClubRooms(List memberships) { - Map clubById = memberships.stream() - .map(ClubMember::getClub) - .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); - - Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) - .stream() - .filter(room -> room.getClub() != null) - .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); - - for (Map.Entry clubEntry : clubById.entrySet()) { - if (roomByClubId.containsKey(clubEntry.getKey())) { - continue; - } - - ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.groupOf(clubEntry.getValue())); - roomByClubId.put(clubEntry.getKey(), createdRoom); - } - - return memberships.stream() - .map(membership -> roomByClubId.get(membership.getClub().getId())) - .toList(); - } - - private void ensureClubRoomMembers( - List rooms, - Map membershipByClubId, - Integer userId - ) { - if (rooms.isEmpty()) { - return; - } - - Map memberByRoomId = chatRoomMemberRepository - .findByChatRoomIdsAndUserId(extractChatRoomIds(rooms), userId) - .stream() - .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); - - for (ChatRoom room : rooms) { - ClubMember member = membershipByClubId.get(room.getClub().getId()); - if (member == null) { - continue; - } - - ChatRoomMember existingMember = memberByRoomId.get(room.getId()); - if (existingMember != null) { - LocalDateTime lastReadAt = existingMember.getLastReadAt(); - if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { - existingMember.updateLastReadAt(member.getCreatedAt()); - } - continue; - } - - chatRoomMemberRepository.save(ChatRoomMember.of(room, member.getUser(), member.getCreatedAt())); - } + private ChatRoom resolveOrCreateClubRoom(Club club) { + return chatRoomRepository.findByClubId(club.getId()) + .orElseGet(() -> chatRoomRepository.save(ChatRoom.groupOf(club))); } private List extractChatRoomIds(List chatRooms) { diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 6a08d2a9..42134e6a 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -52,7 +52,6 @@ List findAllByClubIdAndPosition( SELECT cm FROM ClubMember cm JOIN FETCH cm.club c - JOIN FETCH cm.user WHERE cm.id.userId = :userId """) List findAllByUserId(Integer userId); From 052b7d5ceae517384546afd132be2c5d81724f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:10:27 +0900 Subject: [PATCH 09/55] =?UTF-8?q?chore:=20EOF=20=EC=B6=94=EA=B0=80=20(#423?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java b/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java index 6d471641..4d2ab129 100644 --- a/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java +++ b/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java @@ -129,4 +129,4 @@ void adminOnly() { void multiRole() { } } -} \ No newline at end of file +} From e616c31afe29b9eeeda0f0fa42eb422a10b57903 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:35:21 +0900 Subject: [PATCH 10/55] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C=ED=8A=B8=20API=20MVP=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 구글 스프레드시트 동아리 인명부 동기화 API 구현 * feat: 구글 스프레드시트 동아리 인명부 동기화 API 구현 * fix: 전화번호 앞 0 누락 방지 및 동기화 실패 로그 추가 * feat: 동아리별 스프레드시트 ID 저장 및 sync API 개선 * fix: Checkstyle 120자 초과 라인 수정 * fix: Checkstyle 120자 초과 해결 - Swagger description 영어 변환 * feat: 가입 승인/탈퇴 시 구글 시트 자동 동기화 트리거 * feat: 회비 납부 장부 자동화 - Async/Debounce 시트 동기화 및 정렬 기능 구현 * fix: Checkstyle - MagicNumber 상수 치환 및 NoWhitespaceAfter 수정 * feat: Claude API 기반 시트 헤더 자동 분석 및 커스텀 매핑 동기화 구현 * fix: Checkstyle MagicNumber 상수 치환 * fix: sheetSyncExecutor Bean 이름 충돌 해결 - sheetSyncTaskExecutor로 변경 * fix: SheetHeaderMapper RestClient.Builder로 교체 (Bean 미등록 오류 해결) * fix: @TransactionalEventListener + @Transactional 충돌 해결 - 클래스 레벨 트랜잭션 제거 * feat: 시트 헤더 위치 자동 감지 - 상위 10행 스캔 및 dataStartRow 기반 동기화 개선 * fix: Checkstyle NoWhitespaceAfter 수정 * docs: ClubMemberSheetApi Swagger 설명 한글화 * docs: ClubFeePaymentApi Swagger 설명 한글화 * feat: 회비 장부 별도 탭 자동 감지 및 동기화 구현 - AI가 인명부/회비 장부 탭 자동 구분 * fix: Checkstyle - UnusedImports, LineLength, EqualsAvoidNull 수정 * fix: Checkstyle LineLength 수정 - memberList fields 줄바꿈 * feat: Drive 폴더 관리 + 역방향 동기화 + 기존 시트 팀 양식 이관 구현 * fix: Google Drive API 버전 수정 - v3-rev20250723-2.0.0 * fix: Checkstyle UnusedImports 수정 * revert: 회비 납부(FeePayment) 기능 전체 제거 - 팀 논의 후 재도입 예정 * fix: FeePayment 파일 삭제 누락 수정 (git rm) * fix: ClubSheetSortKey에서 FEE_PAID 제거 * fix: 동기화 시 탈퇴 멤버 행이 스프레드시트에 잔존하는 버그 수정 * feat: 시트 마이그레이션 시 요청자에게 스프레드시트 소유권 이전 * chore: ClubFeePayment 기능 롤백 마이그레이션 추가 * refactor: AI 리뷰어 피드백 반영 * fix: @Transactional self-invocation 버그 및 동기화 비동기 처리 수정 * fix: NOT_FOUND_ADVERTISEMENT 누락 복구 - develop 브랜치 충돌 해결 * fix: Flyway 버전 충돌 해결 - V50~V55를 V51~V56으로 재번호 (develop의 V50__add_advertisement_table과 충돌) * refactor: coderabbitai 재리뷰 피드백 반영 --- build.gradle | 7 + .../club/controller/ClubMemberSheetApi.java | 51 ++++ .../controller/ClubMemberSheetController.java | 46 +++ .../controller/ClubSheetMigrationApi.java | 48 +++ .../ClubSheetMigrationController.java | 50 +++ .../club/dto/ClubMemberSheetSyncRequest.java | 19 ++ .../club/dto/ClubMemberSheetSyncResponse.java | 19 ++ .../club/dto/ClubSheetIdUpdateRequest.java | 19 ++ .../domain/club/dto/SheetImportRequest.java | 19 ++ .../domain/club/dto/SheetImportResponse.java | 9 + .../domain/club/dto/SheetMigrateRequest.java | 19 ++ .../domain/club/enums/ClubSheetSortKey.java | 8 + .../club/event/ClubMemberChangedEvent.java | 9 + .../agit/konect/domain/club/model/Club.java | 24 ++ .../domain/club/model/SheetColumnMapping.java | 63 ++++ .../club/service/ClubApplicationService.java | 2 + .../service/ClubMemberManagementService.java | 9 + .../club/service/ClubMemberSheetService.java | 85 ++++++ .../club/service/SheetHeaderMapper.java | 276 +++++++++++++++++ .../club/service/SheetImportService.java | 121 ++++++++ .../club/service/SheetMigrationService.java | 283 +++++++++++++++++ .../club/service/SheetSyncDebouncer.java | 49 +++ .../club/service/SheetSyncExecutor.java | 288 ++++++++++++++++++ .../konect/global/code/ApiResponseCode.java | 2 + .../konect/global/config/AsyncConfig.java | 31 ++ .../googlesheets/GoogleSheetsConfig.java | 63 ++++ .../googlesheets/GoogleSheetsProperties.java | 10 + .../resources/application-infrastructure.yml | 6 + .../V51__add_google_sheet_id_to_club.sql | 2 + .../V52__add_club_fee_payment_table.sql | 16 + .../V53__add_sheet_column_mapping_to_club.sql | 2 + .../V54__add_fee_sheet_columns_to_club.sql | 3 + ...add_drive_and_template_columns_to_club.sql | 3 + .../V56__rollback_fee_payment_feature.sql | 9 + 34 files changed, 1670 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java create mode 100644 src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java create mode 100644 src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java create mode 100644 src/main/java/gg/agit/konect/global/config/AsyncConfig.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java create mode 100644 src/main/resources/db/migration/V51__add_google_sheet_id_to_club.sql create mode 100644 src/main/resources/db/migration/V52__add_club_fee_payment_table.sql create mode 100644 src/main/resources/db/migration/V53__add_sheet_column_mapping_to_club.sql create mode 100644 src/main/resources/db/migration/V54__add_fee_sheet_columns_to_club.sql create mode 100644 src/main/resources/db/migration/V55__add_drive_and_template_columns_to_club.sql create mode 100644 src/main/resources/db/migration/V56__rollback_fee_payment_feature.sql diff --git a/build.gradle b/build.gradle index d854c5a0..6d8b818f 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,13 @@ dependencies { // notification implementation 'com.google.firebase:firebase-admin:9.2.0' + // Google Sheets API + implementation 'com.google.apis:google-api-services-sheets:v4-rev20251110-2.0.0' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' + + // Google Drive API + implementation 'com.google.apis:google-api-services-drive:v3-rev20250723-2.0.0' + // Gemini AI - using REST API directly (no SDK dependency) // test diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java new file mode 100644 index 00000000..29ebaf24 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -0,0 +1,51 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubMemberSheetApi { + + @Operation( + summary = "구글 스프레드시트 ID 등록 / 수정", + description = "동아리에서 사용 중인 구글 스프레드시트 ID를 등록하거나 수정합니다. " + + "등록 시 AI(Claude Haiku)가 시트 상단 10행을 자동으로 분석하여 " + + "이름·학번·연락처 등 컬럼 위치를 파악하고, 이후 동기화 시 해당 컬럼에만 값을 채웁니다. " + + "시트 양식이 변경된 경우 이 API를 다시 호출하면 AI가 재분석합니다." + ) + @PutMapping("/{clubId}/sheet") + ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "동아리 인명부 스프레드시트 동기화", + description = "등록된 구글 스프레드시트에 동아리 회원 인명부를 동기화합니다. " + + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT)을 지정할 수 있으며, " + + "ascending으로 오름차순/내림차순을 설정합니다. " + + "가입 승인·탈퇴 시에도 자동으로 동기화됩니다." + ) + @PostMapping("/{clubId}/members/sheet-sync") + ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java new file mode 100644 index 00000000..ad374cc8 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java @@ -0,0 +1,46 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.service.ClubMemberSheetService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubMemberSheetController implements ClubMemberSheetApi { + + private final ClubMemberSheetService clubMemberSheetService; + + @Override + public ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ) { + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, + @UserId Integer requesterId + ) { + ClubMemberSheetSyncResponse response = + clubMemberSheetService.syncMembersToSheet(clubId, requesterId, sortKey, ascending); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java new file mode 100644 index 00000000..e8aaaf2f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -0,0 +1,48 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubSheetMigrationApi { + + @Operation( + summary = "기존 스프레드시트 → 팀 양식으로 이관", + description = "동아리가 기존에 사용하던 스프레드시트 URL을 제출하면, " + + "AI가 데이터를 분석하여 KONECT 팀이 마련한 표준 양식 파일로 복사합니다. " + + "새 파일은 기존 URL과 동일한 Google Drive 폴더에 생성됩니다. " + + "이후 동기화는 새로 생성된 파일 기준으로 진행됩니다." + ) + @PostMapping("/{clubId}/sheet/migrate") + ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "기존 스프레드시트에서 사전 회원 가져오기", + description = "동아리가 기존에 관리하던 스프레드시트의 인명부를 읽어 " + + "DB에 사전 회원(ClubPreMember)으로 등록합니다. " + + "AI가 헤더를 자동 분석하며, 이미 등록된 회원(이름+학번 중복)은 건너뜁니다." + ) + @PostMapping("/{clubId}/sheet/import") + ResponseEntity importPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java new file mode 100644 index 00000000..ffde170c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -0,0 +1,50 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.domain.club.service.SheetImportService; +import gg.agit.konect.domain.club.service.SheetMigrationService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubSheetMigrationController implements ClubSheetMigrationApi { + + private final SheetMigrationService sheetMigrationService; + private final SheetImportService sheetImportService; + + @Override + public ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ) { + String newSpreadsheetId = sheetMigrationService.migrateToTemplate( + clubId, requesterId, request.sourceSpreadsheetUrl() + ); + return ResponseEntity.ok(ClubMemberSheetSyncResponse.of(0, newSpreadsheetId)); + } + + @Override + public ResponseEntity importPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ) { + int count = sheetImportService.importPreMembersFromSheet( + clubId, requesterId, request.spreadsheetId() + ); + return ResponseEntity.ok(SheetImportResponse.of(count)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java new file mode 100644 index 00000000..c89419f5 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ClubMemberSheetSyncRequest( + @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Pattern( + regexp = "^[A-Za-z0-9_-]+$", + message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + ) + @Schema( + description = "동기화 대상 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java new file mode 100644 index 00000000..886892b6 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubMemberSheetSyncResponse( + @Schema(description = "동기화된 회원 수", example = "42") + int syncedMemberCount, + + @Schema( + description = "동기화된 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" + ) + String sheetUrl +) { + public static ClubMemberSheetSyncResponse of(int syncedMemberCount, String spreadsheetId) { + String sheetUrl = "https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"; + return new ClubMemberSheetSyncResponse(syncedMemberCount, sheetUrl); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java new file mode 100644 index 00000000..51a34e92 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ClubSheetIdUpdateRequest( + @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Pattern( + regexp = "^[A-Za-z0-9_-]+$", + message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + ) + @Schema( + description = "등록할 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java new file mode 100644 index 00000000..1f733420 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record SheetImportRequest( + @NotBlank + @Pattern( + regexp = "^[A-Za-z0-9_-]+$", + message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + ) + @Schema( + description = "인명부가 담긴 구글 스프레드시트 ID", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java new file mode 100644 index 00000000..91225711 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.dto; + +public record SheetImportResponse( + int importedCount +) { + public static SheetImportResponse of(int importedCount) { + return new SheetImportResponse(importedCount); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java new file mode 100644 index 00000000..a38d99a9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record SheetMigrateRequest( + @NotBlank + @Pattern( + regexp = "^https://docs\\.google\\.com/spreadsheets/.*", + message = "유효한 구글 스프레드시트 URL을 입력해주세요." + ) + @Schema( + description = "동아리가 기존에 사용하던 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5.../edit" + ) + String sourceSpreadsheetUrl +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java new file mode 100644 index 00000000..06f9c43e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java @@ -0,0 +1,8 @@ +package gg.agit.konect.domain.club.enums; + +public enum ClubSheetSortKey { + NAME, + STUDENT_ID, + POSITION, + JOINED_AT +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java new file mode 100644 index 00000000..2afa0b67 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.event; + +public record ClubMemberChangedEvent( + Integer clubId +) { + public static ClubMemberChangedEvent of(Integer clubId) { + return new ClubMemberChangedEvent(clubId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 3e946392..114c1cc8 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -84,6 +84,18 @@ public class Club extends BaseEntity { @Column(name = "is_application_enabled") private Boolean isApplicationEnabled; + @Column(name = "google_sheet_id", length = 255) + private String googleSheetId; + + @Column(name = "sheet_column_mapping", columnDefinition = "JSON") + private String sheetColumnMapping; + + @Column(name = "drive_folder_id", length = 255) + private String driveFolderId; + + @Column(name = "template_spreadsheet_id", length = 255) + private String templateSpreadsheetId; + @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -224,4 +236,16 @@ private void clearFeeInfo() { this.feeAccountNumber = null; this.feeAccountHolder = null; } + + public void updateGoogleSheetId(String googleSheetId) { + this.googleSheetId = googleSheetId; + } + + public void updateSheetColumnMapping(String sheetColumnMapping) { + this.sheetColumnMapping = sheetColumnMapping; + } + + public void updateDriveFolderId(String driveFolderId) { + this.driveFolderId = driveFolderId; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java new file mode 100644 index 00000000..227a4de2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -0,0 +1,63 @@ +package gg.agit.konect.domain.club.model; + +import java.util.HashMap; +import java.util.Map; + +public class SheetColumnMapping { + + public static final String NAME = "name"; + public static final String STUDENT_ID = "studentId"; + public static final String EMAIL = "email"; + public static final String PHONE = "phone"; + public static final String POSITION = "position"; + public static final String JOINED_AT = "joinedAt"; + + private static final int COL_NAME = 0; + private static final int COL_STUDENT_ID = 1; + private static final int COL_EMAIL = 2; + private static final int COL_PHONE = 3; + private static final int COL_POSITION = 4; + private static final int COL_JOINED_AT = 5; + private static final int DEFAULT_DATA_START_ROW = 2; + + private final Map fieldToColumn; + private final int dataStartRow; + + public SheetColumnMapping(Map fieldToColumn, int dataStartRow) { + this.fieldToColumn = new HashMap<>(fieldToColumn); + this.dataStartRow = dataStartRow; + } + + public SheetColumnMapping(Map fieldToColumn) { + this(fieldToColumn, DEFAULT_DATA_START_ROW); + } + + public static SheetColumnMapping defaultMapping() { + Map mapping = new HashMap<>(); + mapping.put(NAME, COL_NAME); + mapping.put(STUDENT_ID, COL_STUDENT_ID); + mapping.put(EMAIL, COL_EMAIL); + mapping.put(PHONE, COL_PHONE); + mapping.put(POSITION, COL_POSITION); + mapping.put(JOINED_AT, COL_JOINED_AT); + return new SheetColumnMapping(mapping, DEFAULT_DATA_START_ROW); + } + + public boolean hasColumn(String field) { + return fieldToColumn.containsKey(field); + } + + public int getColumnIndex(String field) { + return fieldToColumn.getOrDefault(field, -1); + } + + public int getDataStartRow() { + return dataStartRow; + } + + public Map toMap() { + Map result = new HashMap<>(fieldToColumn); + result.put("dataStartRow", dataStartRow); + return result; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 076d7c69..9cc2a9d3 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -31,6 +31,7 @@ import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubApply; import gg.agit.konect.domain.club.model.ClubApplyAnswer; @@ -251,6 +252,7 @@ public void approveClubApplication(Integer clubId, Integer applicationId, Intege clubId, club.getName() )); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } @Transactional diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 5a83be74..7c973d13 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +19,7 @@ import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubPreMember; @@ -42,6 +44,7 @@ public class ClubMemberManagementService { private final ClubPermissionValidator clubPermissionValidator; private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional public ClubMember changeMemberPosition( @@ -72,6 +75,7 @@ public ClubMember changeMemberPosition( validatePositionLimit(clubId, newPosition, target); target.changePosition(newPosition); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return target; } @@ -130,6 +134,7 @@ private ClubPreMemberAddResponse addDirectMember(Club club, User user, ClubPosit ClubMember savedMember = clubMemberRepository.save(clubMember); chatRoomMembershipService.addClubMember(savedMember); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(club.getId())); return ClubPreMemberAddResponse.from(savedMember); } @@ -194,6 +199,7 @@ public List transferPresident( currentPresident.changePosition(MEMBER); newPresident.changePosition(PRESIDENT); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return List.of(currentPresident, newPresident); } @@ -223,6 +229,7 @@ public List changeVicePresident( ClubMember currentVicePresident = currentVicePresidentOpt.get(); currentVicePresident.changePosition(MEMBER); changedMembers.add(currentVicePresident); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } return changedMembers; } @@ -241,6 +248,7 @@ public List changeVicePresident( newVicePresident.changePosition(VICE_PRESIDENT); changedMembers.add(newVicePresident); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return changedMembers; } @@ -273,6 +281,7 @@ public void removeMember(Integer clubId, Integer targetUserId, Integer requester clubMemberRepository.delete(target); chatRoomMembershipService.removeClubMember(clubId, targetUserId); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } private void validateNotSelf(Integer userId1, Integer userId2, ApiResponseCode errorCode) { diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java new file mode 100644 index 00000000..6485f8ae --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -0,0 +1,85 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClubMemberSheetService { + + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubPermissionValidator clubPermissionValidator; + private final SheetSyncDebouncer sheetSyncDebouncer; + private final SheetSyncExecutor sheetSyncExecutor; + private final SheetHeaderMapper sheetHeaderMapper; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onClubMemberChanged(ClubMemberChangedEvent event) { + sheetSyncDebouncer.debounce(event.clubId()); + } + + @Transactional + public void updateSheetId( + Integer clubId, + Integer requesterId, + ClubSheetIdUpdateRequest request + ) { + SheetHeaderMapper.SheetAnalysisResult result = + sheetHeaderMapper.analyzeAllSheets(request.spreadsheetId()); + String mappingJson = null; + try { + mappingJson = objectMapper.writeValueAsString(result.memberListMapping().toMap()); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); + } + + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + club.updateGoogleSheetId(request.spreadsheetId()); + if (mappingJson != null) { + club.updateSheetColumnMapping(mappingJson); + } + } + + @Transactional(readOnly = true) + public ClubMemberSheetSyncResponse syncMembersToSheet( + Integer clubId, + Integer requesterId, + ClubSheetSortKey sortKey, + boolean ascending + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + throw CustomException.of(NOT_FOUND_CLUB_SHEET_ID); + } + + long memberCount = clubMemberRepository.countByClubId(clubId); + sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); + + return ClubMemberSheetSyncResponse.of((int)memberCount, spreadsheetId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java new file mode 100644 index 00000000..5b10c997 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -0,0 +1,276 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.Sheet; +import com.google.api.services.sheets.v4.model.Spreadsheet; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SheetHeaderMapper { + + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + // 헤더 분석용으로 haiku 모델을 고정 사용 (ClaudeProperties.model()과 의도적으로 분리) + // 운영 모델과 다르게 비용/속도 최적화를 위해 저비용 모델을 선택 + private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; + private static final int MAX_TOKENS = 1024; + private static final int SCAN_ROWS = 10; + + private final Sheets googleSheetsService; + private final ClaudeProperties claudeProperties; + private final ObjectMapper objectMapper; + private final RestClient restClient; + + public SheetHeaderMapper( + Sheets googleSheetsService, + ClaudeProperties claudeProperties, + ObjectMapper objectMapper, + RestClient.Builder restClientBuilder + ) { + this.googleSheetsService = googleSheetsService; + this.claudeProperties = claudeProperties; + this.objectMapper = objectMapper; + this.restClient = restClientBuilder.build(); + } + + public record SheetAnalysisResult( + SheetColumnMapping memberListMapping, + Integer feeSheetId, + SheetColumnMapping feeLedgerMapping + ) {} + + public SheetAnalysisResult analyzeAllSheets(String spreadsheetId) { + List sheets = readAllSheets(spreadsheetId); + if (sheets.isEmpty()) { + log.warn("No sheets found. Using default mapping."); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + + try { + return inferAllMappings(spreadsheetId, sheets); + } catch (Exception e) { + log.warn("Sheet analysis failed, using default. cause={}", e.getMessage()); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + } + + private record SheetInfo(Integer sheetId, String title) {} + + private List readAllSheets(String spreadsheetId) { + try { + Spreadsheet spreadsheet = googleSheetsService.spreadsheets() + .get(spreadsheetId) + .execute(); + + List result = new ArrayList<>(); + for (Sheet sheet : spreadsheet.getSheets()) { + result.add(new SheetInfo( + sheet.getProperties().getSheetId(), + sheet.getProperties().getTitle() + )); + } + return result; + + } catch (IOException e) { + log.error("Failed to read spreadsheet info. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private List> readSheetRows(String spreadsheetId, String sheetTitle) { + try { + String range = "'" + sheetTitle + "'!A1:Z10"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + if (values == null || values.isEmpty()) { + return List.of(); + } + + List> rows = new ArrayList<>(); + int limit = Math.min(values.size(), SCAN_ROWS); + for (int i = 0; i < limit; i++) { + List row = values.get(i).stream() + .map(Object::toString) + .toList(); + rows.add(row); + } + return rows; + + } catch (IOException e) { + log.warn("Failed to read rows from sheet '{}'. cause={}", sheetTitle, e.getMessage()); + return List.of(); + } + } + + private SheetAnalysisResult inferAllMappings( + String spreadsheetId, + List sheets + ) throws Exception { + StringBuilder sheetsDescription = new StringBuilder(); + Map>> sheetRowsMap = new HashMap<>(); + + for (SheetInfo sheet : sheets) { + List> rows = readSheetRows(spreadsheetId, sheet.title()); + sheetRowsMap.put(sheet.title(), rows); + + sheetsDescription.append(String.format("=== Sheet: \"%s\" (sheetId: %d) ===%n", + sheet.title(), sheet.sheetId())); + if (rows.isEmpty()) { + sheetsDescription.append("(empty)\n"); + } else { + for (int i = 0; i < rows.size(); i++) { + sheetsDescription.append(String.format("Row %d: %s%n", i + 1, rows.get(i))); + } + } + sheetsDescription.append("\n"); + } + + String prompt = buildPrompt(sheetsDescription.toString(), sheets); + String rawJson = callClaude(prompt); + return parseAllMappings(rawJson, sheets); + } + + private String buildPrompt(String sheetsDescription, List sheets) { + List sheetNames = sheets.stream().map(SheetInfo::title).toList(); + return String.format(""" + A Korean university club uses a Google Spreadsheet with these sheets: + %s + + %s + + Analyze the sheets and respond ONLY with a JSON object in this format: + { + "memberList": { + "sheetTitle": "sheet name containing member list", + "headerRow": 1, + "mapping": {"name": 0, "studentId": 1, "email": 2} + } + } + + Field definitions: + - memberList fields: name(이름/성명), studentId(학번), email(이메일), + phone(전화번호/연락처), position(직책), joinedAt(가입일) + + Rules: + - "memberList.sheetTitle" must be one of: %s + - "headerRow" is 1-indexed + - "mapping" uses 0-indexed column positions + - Only include fields you are confident about + - Do not include explanation + """, + sheetNames, sheetsDescription, sheetNames + ); + } + + private String callClaude(String prompt) { + Map request = Map.of( + "model", MAPPING_MODEL, + "max_tokens", MAX_TOKENS, + "messages", List.of(Map.of("role", "user", "content", prompt)) + ); + + try { + String response = restClient.post() + .uri(API_URL) + .header("x-api-key", claudeProperties.apiKey()) + .header("anthropic-version", ANTHROPIC_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class); + + JsonNode root = objectMapper.readTree(response); + JsonNode content = root.path("content"); + if (!content.isArray() || content.isEmpty()) { + throw new RuntimeException("Claude API returned empty content. response=" + response); + } + return content.get(0).path("text").asText(); + + } catch (RestClientException | IOException e) { + throw new RuntimeException("Claude API call failed", e); + } + } + + private SheetAnalysisResult parseAllMappings( + String rawJson, + List sheets + ) { + try { + String cleaned = rawJson.trim(); + int start = cleaned.indexOf('{'); + int end = cleaned.lastIndexOf('}'); + if (start < 0 || end < 0) { + throw new IllegalArgumentException("No JSON object found"); + } + cleaned = cleaned.substring(start, end + 1); + + JsonNode root = objectMapper.readTree(cleaned); + + SheetColumnMapping memberListMapping = parseSingleMapping(root.path("memberList")); + SheetColumnMapping feeLedgerMapping = null; + Integer feeSheetId = null; + + JsonNode feeLedgerNode = root.path("feeLedger"); + if (!feeLedgerNode.isMissingNode() && !feeLedgerNode.isNull()) { + String feeLedgerTitle = feeLedgerNode.path("sheetTitle").asText(null); + if (feeLedgerTitle != null && !"null".equals(feeLedgerTitle)) { + feeLedgerMapping = parseSingleMapping(feeLedgerNode); + feeSheetId = sheets.stream() + .filter(s -> s.title().equals(feeLedgerTitle)) + .map(SheetInfo::sheetId) + .findFirst() + .orElse(null); + } + } + + log.info( + "Sheet analysis done. memberList={}, feeSheetId={}, feeLedger={}", + memberListMapping.toMap(), feeSheetId, + feeLedgerMapping != null ? feeLedgerMapping.toMap() : "none" + ); + + return new SheetAnalysisResult(memberListMapping, feeSheetId, feeLedgerMapping); + + } catch (Exception e) { + log.warn("Failed to parse all mappings: {}. Using default.", rawJson); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + } + + private SheetColumnMapping parseSingleMapping(JsonNode node) { + int headerRow = Math.max(1, node.path("headerRow").asInt(1)); + int dataStartRow = headerRow + 1; + + JsonNode mappingNode = node.path("mapping"); + Map mapping = new HashMap<>(); + mappingNode.fields().forEachRemaining(entry -> { + int colIndex = entry.getValue().asInt(-1); + if (colIndex >= 0) { + mapping.put(entry.getKey(), colIndex); + } + }); + + return new SheetColumnMapping(mapping, dataStartRow); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java new file mode 100644 index 00000000..7ab32742 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -0,0 +1,121 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetImportService { + + private final Sheets googleSheetsService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + private final ClubPreMemberRepository clubPreMemberRepository; + private final ClubPermissionValidator clubPermissionValidator; + + @Transactional + public int importPreMembersFromSheet( + Integer clubId, + Integer requesterId, + String spreadsheetId + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = clubRepository.getById(clubId); + + SheetHeaderMapper.SheetAnalysisResult analysis = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + SheetColumnMapping mapping = analysis.memberListMapping(); + + List> rows = readDataRows(spreadsheetId, mapping); + int imported = 0; + + for (List row : rows) { + String name = getCell(row, mapping, SheetColumnMapping.NAME); + String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + + if (name.isBlank() || studentNumber.isBlank()) { + continue; + } + + if (clubPreMemberRepository.existsByClubIdAndStudentNumberAndName( + clubId, studentNumber, name + )) { + continue; + } + + String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); + ClubPosition position = resolvePosition(positionStr); + + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(position) + .build(); + + clubPreMemberRepository.save(preMember); + imported++; + } + + log.info( + "Sheet import done. clubId={}, spreadsheetId={}, imported={}", + clubId, spreadsheetId, imported + ); + return imported; + } + + private List> readDataRows(String spreadsheetId, SheetColumnMapping mapping) { + try { + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + log.error("Failed to read sheet data. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private String getCell(List row, SheetColumnMapping mapping, String field) { + int col = mapping.getColumnIndex(field); + if (col < 0 || col >= row.size()) { + return ""; + } + String value = row.get(col).toString().trim(); + if (value.startsWith("'")) { + return value.substring(1); + } + return value; + } + + private ClubPosition resolvePosition(String positionStr) { + for (ClubPosition pos : ClubPosition.values()) { + if (pos.getDescription().equals(positionStr) + || pos.name().equalsIgnoreCase(positionStr)) { + return pos; + } + } + return ClubPosition.MEMBER; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java new file mode 100644 index 00000000..1dcd17ee --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -0,0 +1,283 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import com.google.api.services.drive.model.Permission; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.code.ApiResponseCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetMigrationService { + + private static final Pattern FOLDER_ID_PATTERN = + Pattern.compile("(?:folders/|id=)([a-zA-Z0-9_-]{20,})"); + private static final Pattern SPREADSHEET_ID_PATTERN = + Pattern.compile("/spreadsheets/d/([a-zA-Z0-9_-]+)"); + private static final String MIME_TYPE_SPREADSHEET = + "application/vnd.google-apps.spreadsheet"; + private static final String NEW_SHEET_TITLE_PREFIX = "KONECT_인명부_"; + + @Value("${google.sheets.template-spreadsheet-id:}") + private String defaultTemplateSpreadsheetId; + + private final Drive googleDriveService; + private final Sheets googleSheetsService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + private final UserRepository userRepository; + private final ClubPermissionValidator clubPermissionValidator; + + @Transactional + public String migrateToTemplate( + Integer clubId, + Integer requesterId, + String sourceSpreadsheetUrl + ) { + Club club = clubRepository.getById(clubId); + User requester = userRepository.getById(requesterId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + String templateId = defaultTemplateSpreadsheetId; + + if (templateId == null || templateId.isBlank()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID); + } + + String sourceSpreadsheetId = extractSpreadsheetId(sourceSpreadsheetUrl); + String folderId = resolveFolderId(sourceSpreadsheetUrl, sourceSpreadsheetId); + + String newSpreadsheetId = copyTemplate(templateId, club.getName(), folderId, requester.getEmail()); + + SheetHeaderMapper.SheetAnalysisResult sourceAnalysis = + sheetHeaderMapper.analyzeAllSheets(sourceSpreadsheetId); + + List> sourceData = readAllData( + sourceSpreadsheetId, + sourceAnalysis.memberListMapping() + ); + + writeToTemplate(newSpreadsheetId, sourceData, sourceAnalysis.memberListMapping()); + + club.updateGoogleSheetId(newSpreadsheetId); + if (folderId != null) { + club.updateDriveFolderId(folderId); + } + + SheetHeaderMapper.SheetAnalysisResult newAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + try { + com.fasterxml.jackson.databind.ObjectMapper om = + new com.fasterxml.jackson.databind.ObjectMapper(); + club.updateSheetColumnMapping( + om.writeValueAsString(newAnalysis.memberListMapping().toMap()) + ); + } catch (Exception e) { + log.warn("Failed to serialize new mapping. cause={}", e.getMessage()); + } + + log.info( + "Sheet migration done. clubId={}, sourceId={}, newId={}, folderId={}", + clubId, sourceSpreadsheetId, newSpreadsheetId, folderId + ); + + return newSpreadsheetId; + } + + private String extractSpreadsheetId(String url) { + Matcher m = SPREADSHEET_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + return url; + } + + private String resolveFolderId(String url, String spreadsheetId) { + Matcher m = FOLDER_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + try { + File file = googleDriveService.files().get(spreadsheetId) + .setFields("parents") + .execute(); + List parents = file.getParents(); + if (parents != null && !parents.isEmpty()) { + return parents.get(0); + } + } catch (IOException e) { + log.warn("Failed to get parent folder of spreadsheet. cause={}", e.getMessage()); + } + return null; + } + + private String copyTemplate(String templateId, String clubName, String targetFolderId, String ownerEmail) { + try { + String title = NEW_SHEET_TITLE_PREFIX + clubName; + File copyMetadata = new File().setName(title); + + if (targetFolderId != null) { + copyMetadata.setParents(Collections.singletonList(targetFolderId)); + } + + File copied = googleDriveService.files().copy(templateId, copyMetadata) + .setFields("id") + .execute(); + + log.info("Template copied. newId={}, folderId={}", copied.getId(), targetFolderId); + + transferOwnership(copied.getId(), ownerEmail); + + return copied.getId(); + + } catch (IOException e) { + log.error("Failed to copy template. cause={}", e.getMessage(), e); + throw new RuntimeException("Failed to copy template spreadsheet", e); + } + } + + private void transferOwnership(String fileId, String ownerEmail) { + try { + Permission permission = new Permission() + .setType("user") + .setRole("owner") + .setEmailAddress(ownerEmail); + + googleDriveService.permissions().create(fileId, permission) + .setTransferOwnership(true) + .execute(); + + log.info("Ownership transferred. fileId={}, ownerEmail={}", fileId, ownerEmail); + } catch (IOException e) { + log.warn("Failed to transfer ownership. fileId={}, cause={}", fileId, e.getMessage()); + } + } + + private List> readAllData( + String spreadsheetId, + SheetColumnMapping mapping + ) { + try { + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + log.error("Failed to read source data. cause={}", e.getMessage(), e); + return List.of(); + } + } + + private void writeToTemplate( + String newSpreadsheetId, + List> sourceData, + SheetColumnMapping sourceMapping + ) { + if (sourceData.isEmpty()) { + return; + } + + try { + SheetHeaderMapper.SheetAnalysisResult templateAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + SheetColumnMapping targetMapping = templateAnalysis.memberListMapping(); + int targetDataStartRow = targetMapping.getDataStartRow(); + + Map sourceFieldToCol = buildReverseMapping(sourceMapping); + List> targetRows = new ArrayList<>(); + + for (List sourceRow : sourceData) { + List targetRow = buildTargetRow( + sourceRow, sourceFieldToCol, targetMapping + ); + targetRows.add(targetRow); + } + + String range = "A" + targetDataStartRow; + ValueRange body = new ValueRange().setValues(targetRows); + googleSheetsService.spreadsheets().values() + .update(newSpreadsheetId, range, body) + .setValueInputOption("USER_ENTERED") + .execute(); + + log.info( + "Data written to template. rows={}, targetStartRow={}", + targetRows.size(), targetDataStartRow + ); + + } catch (IOException e) { + log.error("Failed to write data to template. cause={}", e.getMessage(), e); + } + } + + private Map buildReverseMapping(SheetColumnMapping mapping) { + Map result = new java.util.HashMap<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, + SheetColumnMapping.EMAIL, SheetColumnMapping.PHONE, + SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + result.put(field, colIndex); + } + } + return result; + } + + private List buildTargetRow( + List sourceRow, + Map sourceFieldToCol, + SheetColumnMapping targetMapping + ) { + int maxCol = targetMapping.toMap().values().stream() + .filter(v -> v instanceof Integer) + .mapToInt(v -> (Integer)v) + .max() + .orElse(0); + + List row = new ArrayList<>( + Collections.nCopies(maxCol + 1, "") + ); + + for (Map.Entry entry : sourceFieldToCol.entrySet()) { + String field = entry.getKey(); + int sourceCol = entry.getValue(); + int targetCol = targetMapping.getColumnIndex(field); + + if (targetCol >= 0 && sourceCol < sourceRow.size()) { + row.set(targetCol, sourceRow.get(sourceCol)); + } + } + + return row; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java new file mode 100644 index 00000000..a80dadda --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java @@ -0,0 +1,49 @@ +package gg.agit.konect.domain.club.service; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import jakarta.annotation.PreDestroy; + +import org.springframework.stereotype.Component; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncDebouncer { + + private static final long DEBOUNCE_DELAY_SECONDS = 3; + + private final ConcurrentHashMap> pendingTasks = + new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(); + + private final SheetSyncExecutor sheetSyncExecutor; + + @PreDestroy + public void shutdown() { + scheduler.shutdown(); + log.info("SheetSyncDebouncer scheduler shutdown."); + } + + public void debounce(Integer clubId) { + pendingTasks.compute(clubId, (id, existing) -> { + if (existing != null && !existing.isDone()) { + existing.cancel(false); + log.debug("Sheet sync debounced. clubId={}", id); + } + return scheduler.schedule(() -> { + pendingTasks.remove(id); + sheetSyncExecutor.executeWithSort(id, ClubSheetSortKey.POSITION, true); + }, DEBOUNCE_DELAY_SECONDS, TimeUnit.SECONDS); + }); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java new file mode 100644 index 00000000..a6eea1fc --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -0,0 +1,288 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.BasicFilter; +import com.google.api.services.sheets.v4.model.BatchClearValuesRequest; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest; +import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.GridProperties; +import com.google.api.services.sheets.v4.model.GridRange; +import com.google.api.services.sheets.v4.model.Request; +import com.google.api.services.sheets.v4.model.SetBasicFilterRequest; +import com.google.api.services.sheets.v4.model.SheetProperties; +import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncExecutor { + + private static final String SHEET_RANGE = "A1"; + private static final int ALPHABET_SIZE = 26; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final List HEADER_ROW = List.of( + "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt" + ); + + private final Sheets googleSheetsService; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ObjectMapper objectMapper; + + @Async("sheetSyncTaskExecutor") + @Transactional(readOnly = true) + public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { + Club club = clubRepository.getById(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + return; + } + + SheetColumnMapping mapping = resolveMapping(club); + List members = clubMemberRepository.findAllByClubId(clubId); + List sorted = sort(members, sortKey, ascending); + + try { + if (club.getSheetColumnMapping() != null) { + updateMappedColumns(spreadsheetId, sorted, mapping); + } else { + clearAndWriteAll(spreadsheetId, sorted); + applyFormat(spreadsheetId); + } + log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); + } catch (IOException e) { + log.error( + "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", + clubId, spreadsheetId, e.getMessage(), e + ); + } + } + + private SheetColumnMapping resolveRawMapping(String mappingJson) { + try { + Map raw = objectMapper.readValue( + mappingJson, new TypeReference<>() {} + ); + int dataStartRow = raw.containsKey("dataStartRow") + ? ((Number)raw.get("dataStartRow")).intValue() : 2; + Map fieldMap = new HashMap<>(); + raw.forEach((key, value) -> { + if (!"dataStartRow".equals(key) && value instanceof Number num) { + fieldMap.put(key, num.intValue()); + } + }); + return new SheetColumnMapping(fieldMap, dataStartRow); + } catch (Exception e) { + log.warn("Failed to parse raw mapping, using default. cause={}", e.getMessage()); + return SheetColumnMapping.defaultMapping(); + } + } + + private SheetColumnMapping resolveMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + return SheetColumnMapping.defaultMapping(); + } + return resolveRawMapping(mappingJson); + } + + private void updateMappedColumns( + String spreadsheetId, + List members, + SheetColumnMapping mapping + ) throws IOException { + int dataStartRow = mapping.getDataStartRow(); + clearMappedColumns(spreadsheetId, mapping, dataStartRow); + Map> columnData = buildColumnData(members, mapping); + + List data = new ArrayList<>(); + for (Map.Entry> entry : columnData.entrySet()) { + int colIndex = entry.getKey(); + String colLetter = columnLetter(colIndex); + String range = colLetter + dataStartRow + ":" + colLetter; + List> wrapped = + entry.getValue().stream().map(v -> List.of((Object)v)).toList(); + data.add(new ValueRange().setRange(range).setValues(wrapped)); + } + + if (!data.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchUpdate(spreadsheetId, + new BatchUpdateValuesRequest() + .setValueInputOption("USER_ENTERED") + .setData(data)) + .execute(); + } + } + + private void clearMappedColumns( + String spreadsheetId, + SheetColumnMapping mapping, + int dataStartRow + ) throws IOException { + List clearRanges = new ArrayList<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, SheetColumnMapping.EMAIL, + SheetColumnMapping.PHONE, SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + String colLetter = columnLetter(colIndex); + clearRanges.add(colLetter + dataStartRow + ":" + colLetter); + } + } + if (!clearRanges.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchClear(spreadsheetId, new BatchClearValuesRequest().setRanges(clearRanges)) + .execute(); + } + } + + private Map> buildColumnData( + List members, + SheetColumnMapping mapping + ) { + Map> columns = new HashMap<>(); + + for (ClubMember member : members) { + putValue(columns, mapping, SheetColumnMapping.NAME, + member.getUser().getName()); + putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, + member.getUser().getStudentNumber()); + putValue(columns, mapping, SheetColumnMapping.EMAIL, + member.getUser().getEmail()); + putValue(columns, mapping, SheetColumnMapping.PHONE, + member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : ""); + putValue(columns, mapping, SheetColumnMapping.POSITION, + member.getClubPosition().getDescription()); + putValue(columns, mapping, SheetColumnMapping.JOINED_AT, + member.getCreatedAt().format(DATE_FORMATTER)); + } + + return columns; + } + + private void putValue( + Map> columns, + SheetColumnMapping mapping, + String field, + Object value + ) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + columns.computeIfAbsent(colIndex, k -> new ArrayList<>()).add(value); + } + } + + private void clearAndWriteAll( + String spreadsheetId, + List members + ) throws IOException { + String clearRange = "A:F"; + googleSheetsService.spreadsheets().values() + .clear(spreadsheetId, clearRange, new ClearValuesRequest()) + .execute(); + + List> rows = new ArrayList<>(); + rows.add(HEADER_ROW); + + for (ClubMember member : members) { + String phone = member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : ""; + rows.add(List.of( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + phone, + member.getClubPosition().getDescription(), + member.getCreatedAt().format(DATE_FORMATTER) + )); + } + + ValueRange body = new ValueRange().setValues(rows); + googleSheetsService.spreadsheets().values() + .update(spreadsheetId, SHEET_RANGE, body) + .setValueInputOption("USER_ENTERED") + .execute(); + } + + private void applyFormat(String spreadsheetId) throws IOException { + List requests = new ArrayList<>(); + + requests.add(new Request().setUpdateSheetProperties( + new UpdateSheetPropertiesRequest() + .setProperties(new SheetProperties() + .setGridProperties(new GridProperties().setFrozenRowCount(1))) + .setFields("gridProperties.frozenRowCount") + )); + + requests.add(new Request().setSetBasicFilter( + new SetBasicFilterRequest() + .setFilter(new BasicFilter() + .setRange(new GridRange().setSheetId(0))) + )); + + googleSheetsService.spreadsheets() + .batchUpdate(spreadsheetId, new BatchUpdateSpreadsheetRequest().setRequests(requests)) + .execute(); + } + + private List sort( + List members, + ClubSheetSortKey sortKey, + boolean ascending + ) { + Comparator comparator = switch (sortKey) { + case NAME -> Comparator.comparing(m -> m.getUser().getName()); + case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); + case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); + case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); + + }; + + if (!ascending) { + comparator = comparator.reversed(); + } + + return members.stream().sorted(comparator).toList(); + } + + private String columnLetter(int index) { + StringBuilder sb = new StringBuilder(); + index++; + while (index > 0) { + index--; + sb.insert(0, (char)('A' + index % ALPHABET_SIZE)); + index /= ALPHABET_SIZE; + } + return sb.toString(); + } +} diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 599ba0d3..682a9749 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -92,6 +92,7 @@ public enum ApiResponseCode { NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), + NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), @@ -116,6 +117,7 @@ public enum ApiResponseCode { // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), + FAILED_SYNC_GOOGLE_SHEET(HttpStatus.INTERNAL_SERVER_ERROR, "구글 스프레드시트 동기화에 실패했습니다."), FAILED_SEND_NOTIFICATION(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다."), UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다."); diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java new file mode 100644 index 00000000..bfc63895 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -0,0 +1,31 @@ +package gg.agit.konect.global.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + private static final int SHEET_SYNC_CORE_POOL_SIZE = 2; + private static final int SHEET_SYNC_MAX_POOL_SIZE = 4; + private static final int SHEET_SYNC_QUEUE_CAPACITY = 50; + private static final int SHEET_SYNC_AWAIT_TERMINATION_SECONDS = 30; + + @Bean(name = "sheetSyncTaskExecutor") + public Executor sheetSyncTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(SHEET_SYNC_CORE_POOL_SIZE); + executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); + executor.setQueueCapacity(SHEET_SYNC_QUEUE_CAPACITY); + executor.setThreadNamePrefix("sheet-sync-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(SHEET_SYNC_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java new file mode 100644 index 00000000..8467c3c9 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -0,0 +1,63 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.SheetsScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class GoogleSheetsConfig { + + private final GoogleSheetsProperties googleSheetsProperties; + + @Bean + public GoogleCredentials googleCredentials() throws IOException { + try (InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath())) { + return GoogleCredentials.fromStream(in) + .createScoped(Arrays.asList( + SheetsScopes.SPREADSHEETS, + DriveScopes.DRIVE + )); + } + } + + @Bean + public Sheets googleSheetsService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { + return new Sheets.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(googleCredentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } + + @Bean + public Drive googleDriveService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(googleCredentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java new file mode 100644 index 00000000..b8cd5882 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java @@ -0,0 +1,10 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "google.sheets") +public record GoogleSheetsProperties( + String credentialsPath, + String applicationName +) { +} diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index b44991e1..a92a092a 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -20,3 +20,9 @@ claude: mcp: url: ${MCP_BRIDGE_URL:http://localhost:3100} + +google: + sheets: + credentials-path: ${GOOGLE_SHEETS_CREDENTIALS_PATH} + application-name: ${GOOGLE_SHEETS_APP_NAME:KONECT} + template-spreadsheet-id: ${GOOGLE_SHEETS_TEMPLATE_ID:} diff --git a/src/main/resources/db/migration/V51__add_google_sheet_id_to_club.sql b/src/main/resources/db/migration/V51__add_google_sheet_id_to_club.sql new file mode 100644 index 00000000..722c7178 --- /dev/null +++ b/src/main/resources/db/migration/V51__add_google_sheet_id_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN google_sheet_id VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V52__add_club_fee_payment_table.sql b/src/main/resources/db/migration/V52__add_club_fee_payment_table.sql new file mode 100644 index 00000000..08420496 --- /dev/null +++ b/src/main/resources/db/migration/V52__add_club_fee_payment_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE club_fee_payment ( + id INT NOT NULL AUTO_INCREMENT, + club_id INT NOT NULL, + user_id INT NOT NULL, + is_paid TINYINT(1) NOT NULL DEFAULT 0, + payment_image_url VARCHAR(255), + approved_at TIMESTAMP NULL, + approved_by INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT fk_fee_payment_club FOREIGN KEY (club_id) REFERENCES club (id), + CONSTRAINT fk_fee_payment_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_fee_payment_approved_by FOREIGN KEY (approved_by) REFERENCES users (id), + UNIQUE KEY uq_fee_payment_club_user (club_id, user_id) +); diff --git a/src/main/resources/db/migration/V53__add_sheet_column_mapping_to_club.sql b/src/main/resources/db/migration/V53__add_sheet_column_mapping_to_club.sql new file mode 100644 index 00000000..7e9a1be3 --- /dev/null +++ b/src/main/resources/db/migration/V53__add_sheet_column_mapping_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN sheet_column_mapping JSON NULL; diff --git a/src/main/resources/db/migration/V54__add_fee_sheet_columns_to_club.sql b/src/main/resources/db/migration/V54__add_fee_sheet_columns_to_club.sql new file mode 100644 index 00000000..8ff2b6e0 --- /dev/null +++ b/src/main/resources/db/migration/V54__add_fee_sheet_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN fee_sheet_id INT NULL, + ADD COLUMN fee_sheet_column_mapping JSON NULL; diff --git a/src/main/resources/db/migration/V55__add_drive_and_template_columns_to_club.sql b/src/main/resources/db/migration/V55__add_drive_and_template_columns_to_club.sql new file mode 100644 index 00000000..4bdaf225 --- /dev/null +++ b/src/main/resources/db/migration/V55__add_drive_and_template_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN drive_folder_id VARCHAR(255) NULL, + ADD COLUMN template_spreadsheet_id VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V56__rollback_fee_payment_feature.sql b/src/main/resources/db/migration/V56__rollback_fee_payment_feature.sql new file mode 100644 index 00000000..d2d64de5 --- /dev/null +++ b/src/main/resources/db/migration/V56__rollback_fee_payment_feature.sql @@ -0,0 +1,9 @@ +-- V52 rollback: drop club_fee_payment table +DROP TABLE IF EXISTS club_fee_payment; + +-- V54 rollback: drop fee_sheet columns from club +ALTER TABLE club + DROP COLUMN fee_sheet_id; + +ALTER TABLE club + DROP COLUMN fee_sheet_column_mapping; From fcdedca1cccf1b42aafa0027fb77283889267e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:32:27 +0900 Subject: [PATCH 11/55] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20=ED=95=B4=EC=83=81?= =?UTF-8?q?=EB=8F=84=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 해상도 크기 검증 로직 제거 * feat: 파일 업로드 크기 초과 예외 처리 * fix: 파일 크기 초과 에러 코드 수정 * fix: 파일 크기 검증 로직 제거 * chore: 코드 포맷팅 --- .../domain/upload/controller/UploadApi.java | 6 +++--- .../upload/service/ImageConversionService.java | 15 --------------- .../domain/upload/service/UploadService.java | 4 ---- .../agit/konect/global/code/ApiResponseCode.java | 3 +++ .../global/exception/GlobalExceptionHandler.java | 6 ++++++ .../integration/domain/upload/UploadApiTest.java | 6 +++--- 6 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java index 036448aa..f070652a 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java @@ -20,15 +20,15 @@ public interface UploadApi { @Operation(summary = "이미지 파일을 업로드한다.", description = """ 서버가 multipart 파일을 받아 S3에 업로드합니다. - + - target 쿼리파라미터로 이미지 저장 대상 도메인을 지정합니다. (CLUB, BANK, COUNCIL, USER) - 응답의 fileUrl을 기존 도메인 API의 imageUrl로 사용합니다. - + ## 에러 - MISSING_ACCESS_TOKEN (401): 액세스 토큰이 필요합니다. - INVALID_REQUEST_BODY (400): 파일이 비어있거나 요청 형식이 올바르지 않은 경우 - INVALID_FILE_CONTENT_TYPE (400): 지원하지 않는 Content-Type 인 경우 - - INVALID_FILE_SIZE (400): 파일 크기가 제한을 초과한 경우 + - PAYLOAD_TOO_LARGE (413): 파일 크기가 제한을 초과한 경우 - FAILED_UPLOAD_FILE (500): S3 업로드에 실패한 경우 """) @PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java index d42182b2..c1b8f7af 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java @@ -33,8 +33,6 @@ public class ImageConversionService { private static final float DEFAULT_WEBP_QUALITY = 0.8f; private static final int WEBP_QUALITY_PERCENT_SCALE = 100; - private static final int MAX_IMAGE_DIMENSION = 8000; - private static final int ORIENTATION_NORMAL = 1; private static final int ORIENTATION_FLIP_HORIZONTAL = 2; private static final int ORIENTATION_ROTATE_180 = 3; @@ -62,8 +60,6 @@ public ConversionResult convertToWebP(MultipartFile file) throws IOException { ImageReader reader = readers.next(); try { - validateImageDimensions(reader, iis); - ImageReadParam readParam = reader.getDefaultReadParam(); BufferedImage image = reader.read(0, readParam); @@ -83,17 +79,6 @@ public ConversionResult convertToWebP(MultipartFile file) throws IOException { } } - private void validateImageDimensions(ImageReader reader, ImageInputStream iis) throws IOException { - reader.setInput(iis); - int width = reader.getWidth(0); - int height = reader.getHeight(0); - - if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { - log.warn("이미지 해상도 초과: {}x{} (최대 {}px)", width, height, MAX_IMAGE_DIMENSION); - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } - } - private BufferedImage applyExifOrientation(ImageReader reader, BufferedImage image) { try { IIOMetadata metadata = reader.getImageMetadata(0); diff --git a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java index e34acb3a..d684ab2b 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -102,10 +102,6 @@ private void validateFile(MultipartFile file) { throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); } - Long maxUploadBytes = s3StorageProperties.maxUploadBytes(); - if (maxUploadBytes != null && file.getSize() > maxUploadBytes) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_SIZE); - } } private String buildKey(String extension, UploadTarget target) { diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 682a9749..aa07002c 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -114,6 +114,9 @@ public enum ApiResponseCode { OAUTH_ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "해당 OAuth 계정은 이미 다른 사용자에게 연동되어 있습니다."), OAUTH_PROVIDER_ALREADY_LINKED(HttpStatus.CONFLICT, "이 계정에는 해당 OAuth 제공자가 이미 연동되어 있습니다."), + // 413 Payload Too Large (요청 본문 크기 초과) + PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."), + // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index fb8b002c..d580eb35 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.apache.catalina.connector.ClientAbortException; import org.slf4j.Logger; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; @@ -75,6 +76,11 @@ public ResponseEntity handleClientAbortException() { return buildErrorResponse(ApiResponseCode.CLIENT_ABORTED); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceededException() { + return buildErrorResponse(ApiResponseCode.PAYLOAD_TOO_LARGE); + } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleMethodArgumentTypeMismatchException() { return buildErrorResponse(ApiResponseCode.INVALID_TYPE_VALUE); diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index 987b5fce..99a08f9c 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -135,7 +135,7 @@ void uploadImageWithBlankContentTypeFails() throws Exception { } @Test - @DisplayName("최대 업로드 크기를 넘기면 400을 반환한다") + @DisplayName("최대 업로드 크기를 넘기면 413을 반환한다") void uploadImageWithTooLargeFileFails() throws Exception { // given MockMultipartFile file = imageFile( @@ -146,8 +146,8 @@ void uploadImageWithTooLargeFileFails() throws Exception { // when & then uploadImage(file, UploadTarget.CLUB) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("INVALID_FILE_SIZE")); + .andExpect(status().isPayloadTooLarge()) + .andExpect(jsonPath("$.code").value("PAYLOAD_TOO_LARGE")); verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); } From 6195ed8853e3d9257f81d9ec4785aacae980c1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:49:18 +0900 Subject: [PATCH 12/55] =?UTF-8?q?fix:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9A=A9=EB=9F=89=20=EC=B4=88=EA=B3=BC=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=B6=80=EB=AA=A8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=94=A9=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/global/exception/GlobalExceptionHandler.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index d580eb35..64f13080 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -76,8 +76,13 @@ public ResponseEntity handleClientAbortException() { return buildErrorResponse(ApiResponseCode.CLIENT_ABORTED); } - @ExceptionHandler(MaxUploadSizeExceededException.class) - public ResponseEntity handleMaxUploadSizeExceededException() { + @Override + protected ResponseEntity handleMaxUploadSizeExceededException( + MaxUploadSizeExceededException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { return buildErrorResponse(ApiResponseCode.PAYLOAD_TOO_LARGE); } From 9f7c2643ec44d871cf4e04f913898c67bd1c942d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:11:49 +0900 Subject: [PATCH 13/55] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A7=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: gitignore 항목 추가 * fix: JPEG 업로드 시 ImageReader 입력 바인딩 누락으로 인한 변환 실패를 방지 * test: 업로드 테스트 추가 * test: 다른 확장자에 대한 이미지 업로드 테스트 * feat: 이미지 리사이징 로직 추가 * test: 리사이징 검증 테스트 추가 * test: 리사이징 단위 테스트 추가 * chore: 인증 인터셉터 단위 테스트 패키지 이동 * chore: 코드 포맷팅 * chore: gitignore 하위 디렉토리도 적용 * fix: webp 이미지도 리사이징 거치도록 수정 * fix: 리사이징에서 가로 최대 크기를 1800px로 수정 * fix: 이미지 리사이징 시 투명 픽셀이 검은 색으로 변하는 문제 해결 * chore: 코드 포맷팅 * fix: 이미지 리사이징 시 품질, 크기 보수적으로 수정 --- .gitignore | 2 + .../service/ImageConversionService.java | 122 +++++++++++--- .../domain/upload/UploadApiTest.java | 153 ++++++++++++++++++ .../web}/AuthorizationInterceptorTest.java | 2 +- .../service/ImageConversionServiceTest.java | 137 ++++++++++++++++ 5 files changed, 396 insertions(+), 20 deletions(-) rename src/test/java/gg/agit/konect/unit/{auth => global/auth/web}/AuthorizationInterceptorTest.java (98%) create mode 100644 src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java diff --git a/.gitignore b/.gitignore index e6cc1750..5f7c4977 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ logs ### MCP Bridge ### mcp-bridge/node_modules/ mcp-bridge/.env + +**/google-service-account.json diff --git a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java index c1b8f7af..6cc54fca 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java @@ -18,6 +18,7 @@ import org.springframework.web.multipart.MultipartFile; import com.sksamuel.scrimage.AwtImage; +import com.sksamuel.scrimage.ImmutableImage; import com.sksamuel.scrimage.webp.WebpWriter; import gg.agit.konect.global.code.ApiResponseCode; @@ -30,8 +31,10 @@ public class ImageConversionService { private static final Set SKIP_CONVERSION_TYPES = Set.of("image/webp"); - private static final float DEFAULT_WEBP_QUALITY = 0.8f; + private static final float DEFAULT_WEBP_QUALITY = 1.0f; private static final int WEBP_QUALITY_PERCENT_SCALE = 100; + private static final int MAX_UPLOAD_WIDTH = 1800; + private static final int MAX_WEBP_DIMENSION = 16383; private static final int ORIENTATION_NORMAL = 1; private static final int ORIENTATION_FLIP_HORIZONTAL = 2; @@ -44,10 +47,10 @@ public class ImageConversionService { public ConversionResult convertToWebP(MultipartFile file) throws IOException { String contentType = file.getContentType(); + boolean isWebp = contentType != null && SKIP_CONVERSION_TYPES.contains(contentType.toLowerCase()); - if (contentType != null && SKIP_CONVERSION_TYPES.contains(contentType.toLowerCase())) { - log.debug("WEBP 이미지는 변환을 건너뜁니다: contentType={}", contentType); - return new ConversionResult(file.getBytes(), contentType, getExtension(contentType)); + if (isWebp) { + return normalizeWebp(file, contentType); } try (InputStream input = file.getInputStream(); @@ -60,6 +63,9 @@ public ConversionResult convertToWebP(MultipartFile file) throws IOException { ImageReader reader = readers.next(); try { + // TwelveMonkeys reader requires the ImageInputStream to be bound explicitly + // before read/getImageMetadata; otherwise JPEG uploads can fail with getInput() == null. + reader.setInput(iis); ImageReadParam readParam = reader.getDefaultReadParam(); BufferedImage image = reader.read(0, readParam); @@ -67,7 +73,16 @@ public ConversionResult convertToWebP(MultipartFile file) throws IOException { throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); } + int originalWidth = image.getWidth(); + int originalHeight = image.getHeight(); image = applyExifOrientation(reader, image); + image = resizeToMaxWidthIfNeeded(image); + image = resizeForWebpIfNeeded(image); + + if (isWebp && originalWidth == image.getWidth() && originalHeight == image.getHeight()) { + log.debug("WEBP 이미지는 크기 변경이 없어 원본을 유지합니다: contentType={}", contentType); + return new ConversionResult(file.getBytes(), contentType, getExtension(contentType)); + } byte[] webpBytes = convertImageToWebP(image, DEFAULT_WEBP_QUALITY); log.info("이미지 WEBP 변환 완료: 원본 {} bytes → WEBP {} bytes", file.getSize(), webpBytes.length); @@ -79,6 +94,27 @@ public ConversionResult convertToWebP(MultipartFile file) throws IOException { } } + private ConversionResult normalizeWebp(MultipartFile file, String contentType) throws IOException { + BufferedImage image = ImmutableImage.loader().fromBytes(file.getBytes()).awt(); + if (image == null) { + throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); + } + + int originalWidth = image.getWidth(); + int originalHeight = image.getHeight(); + image = resizeToMaxWidthIfNeeded(image); + image = resizeForWebpIfNeeded(image); + + if (originalWidth == image.getWidth() && originalHeight == image.getHeight()) { + log.debug("WEBP 이미지는 크기 변경이 없어 원본을 유지합니다: contentType={}", contentType); + return new ConversionResult(file.getBytes(), contentType, getExtension(contentType)); + } + + byte[] webpBytes = convertImageToWebP(image, DEFAULT_WEBP_QUALITY); + log.info("WEBP 이미지 크기 정규화 완료: 원본 {} bytes → WEBP {} bytes", file.getSize(), webpBytes.length); + return new ConversionResult(webpBytes, "image/webp", "webp"); + } + private BufferedImage applyExifOrientation(ImageReader reader, BufferedImage image) { try { IIOMetadata metadata = reader.getImageMetadata(0); @@ -156,9 +192,7 @@ private BufferedImage rotateImage(BufferedImage image, int orientation) { private BufferedImage rotate90(BufferedImage image) { int w = image.getWidth(); int h = image.getHeight(); - BufferedImage rotated = new BufferedImage(h, - w, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); + BufferedImage rotated = createCompatibleImage(image, h, w); Graphics2D g = rotated.createGraphics(); g.translate((h - w) / 2, (h - w) / 2); g.rotate(Math.PI / 2, h / 2.0, w / 2.0); @@ -170,9 +204,7 @@ private BufferedImage rotate90(BufferedImage image) { private BufferedImage rotate180(BufferedImage image) { int w = image.getWidth(); int h = image.getHeight(); - BufferedImage rotated = new BufferedImage(w, - h, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); + BufferedImage rotated = createCompatibleImage(image, w, h); Graphics2D g = rotated.createGraphics(); g.rotate(Math.PI, w / 2.0, h / 2.0); g.drawRenderedImage(image, null); @@ -183,9 +215,7 @@ private BufferedImage rotate180(BufferedImage image) { private BufferedImage rotate270(BufferedImage image) { int w = image.getWidth(); int h = image.getHeight(); - BufferedImage rotated = new BufferedImage(h, - w, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); + BufferedImage rotated = createCompatibleImage(image, h, w); Graphics2D g = rotated.createGraphics(); g.translate((h - w) / 2, (h - w) / 2); g.rotate(-Math.PI / 2, h / 2.0, w / 2.0); @@ -197,9 +227,7 @@ private BufferedImage rotate270(BufferedImage image) { private BufferedImage flipHorizontal(BufferedImage image) { int w = image.getWidth(); int h = image.getHeight(); - BufferedImage flipped = new BufferedImage(w, - h, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); + BufferedImage flipped = createCompatibleImage(image, w, h); Graphics2D g = flipped.createGraphics(); g.drawImage(image, w, 0, -w, h, null); g.dispose(); @@ -209,9 +237,7 @@ private BufferedImage flipHorizontal(BufferedImage image) { private BufferedImage flipVertical(BufferedImage image) { int w = image.getWidth(); int h = image.getHeight(); - BufferedImage flipped = new BufferedImage(w, - h, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); + BufferedImage flipped = createCompatibleImage(image, w, h); Graphics2D g = flipped.createGraphics(); g.drawImage(image, 0, h, w, -h, null); g.dispose(); @@ -227,6 +253,64 @@ private byte[] convertImageToWebP(BufferedImage image, float quality) throws IOE } } + private boolean exceedsWebpDimension(BufferedImage image) { + return image.getWidth() > MAX_WEBP_DIMENSION || image.getHeight() > MAX_WEBP_DIMENSION; + } + + private BufferedImage resizeToMaxWidthIfNeeded(BufferedImage image) { + if (image.getWidth() <= MAX_UPLOAD_WIDTH) { + return image; + } + + int resizedHeight = Math.max(1, + (int)Math.floor((double)image.getHeight() * MAX_UPLOAD_WIDTH / image.getWidth())); + return resizeImage(image, MAX_UPLOAD_WIDTH, resizedHeight, "업로드 최대 가로 길이에 맞게 이미지를 축소합니다"); + } + + private BufferedImage resizeForWebpIfNeeded(BufferedImage image) { + if (!exceedsWebpDimension(image)) { + return image; + } + + int originalWidth = image.getWidth(); + int originalHeight = image.getHeight(); + double scale = Math.min( + (double)MAX_WEBP_DIMENSION / originalWidth, + (double)MAX_WEBP_DIMENSION / originalHeight + ); + int resizedWidth = Math.max(1, (int)Math.floor(originalWidth * scale)); + int resizedHeight = Math.max(1, (int)Math.floor(originalHeight * scale)); + return resizeImage(image, resizedWidth, resizedHeight, "WebP 차원 제한에 맞게 이미지를 축소합니다"); + } + + private BufferedImage resizeImage(BufferedImage image, int resizedWidth, int resizedHeight, String logMessage) { + BufferedImage resized = createCompatibleImage(image, resizedWidth, resizedHeight); + Graphics2D g = resized.createGraphics(); + g.drawImage(image, 0, 0, resizedWidth, resizedHeight, null); + g.dispose(); + + log.info( + "{}: {}x{} -> {}x{}", + logMessage, + image.getWidth(), + image.getHeight(), + resizedWidth, + resizedHeight + ); + return resized; + } + + private BufferedImage createCompatibleImage(BufferedImage source, int width, int height) { + return new BufferedImage(width, height, resolveBufferedImageType(source)); + } + + private int resolveBufferedImageType(BufferedImage image) { + if (image.getType() != 0) { + return image.getType(); + } + return image.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB; + } + private int toWebpQualityPercent(float quality) { if (quality <= 0) { return 0; diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index 99a08f9c..3b5b0cc2 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -25,6 +25,10 @@ import org.springframework.test.web.servlet.ResultActions; import com.jayway.jsonpath.JsonPath; +import com.google.auth.oauth2.GoogleCredentials; +import com.sksamuel.scrimage.AwtImage; +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; import gg.agit.konect.domain.upload.enums.UploadTarget; import gg.agit.konect.support.IntegrationTestSupport; @@ -42,6 +46,9 @@ class UploadApiTest extends IntegrationTestSupport { @MockitoBean private S3Client s3Client; + @MockitoBean + private GoogleCredentials googleCredentials; + @BeforeEach void setUp() throws Exception { mockLoginUser(LOGIN_USER_ID); @@ -78,6 +85,139 @@ void uploadImageSuccess() throws Exception { assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); } + @Test + @DisplayName("jpeg 이미지를 업로드하면 webp 로 변환해 저장한다") + void uploadJpegImageSuccess() throws Exception { + // given + MockMultipartFile file = imageFile("club.jpg", "image/jpeg", createJpegBytes(8, 8)); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".webp"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + } + + @Test + @DisplayName("jpg content type 이미지를 업로드하면 webp 로 변환해 저장한다") + void uploadJpgContentTypeImageSuccess() throws Exception { + // given + MockMultipartFile file = imageFile("club.jpg", "image/jpg", createJpegBytes(8, 8)); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".webp"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + } + + @Test + @DisplayName("webp 이미지를 업로드하면 변환 없이 그대로 저장한다") + void uploadWebpImageSuccess() throws Exception { + // given + byte[] webpBytes = createWebpBytes(8, 8); + MockMultipartFile file = imageFile("club.webp", "image/webp", webpBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".webp"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture()); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream); + assertThat(outputStream.toByteArray()).isEqualTo(webpBytes); + } + + @Test + @DisplayName("가로가 1800을 넘는 webp 이미지는 비율 유지로 축소한 뒤 다시 webp 로 업로드한다") + void uploadWideWebpImageResizesAndKeepsWebp() throws Exception { + byte[] webpBytes = createWebpBytes(2160, 1080); + MockMultipartFile file = imageFile("wide.webp", "image/webp", webpBytes); + + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".webp"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture()); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream); + BufferedImage uploadedImage = ImmutableImage.loader().fromBytes(outputStream.toByteArray()).awt(); + assertThat(uploadedImage).isNotNull(); + assertThat(uploadedImage.getWidth()).isEqualTo(1800); + assertThat(uploadedImage.getHeight()).isEqualTo(900); + assertThat(outputStream.toByteArray()).isNotEqualTo(webpBytes); + } + + @Test + @DisplayName("가로가 1800을 넘는 이미지는 비율 유지로 축소한 뒤 webp 로 업로드한다") + void uploadWideImageResizesAndConvertsToWebP() throws Exception { + MockMultipartFile file = imageFile("wide.png", "image/png", createPngBytes(2160, 1080)); + + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".webp"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture()); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream); + BufferedImage uploadedImage = ImmutableImage.loader().fromBytes(outputStream.toByteArray()).awt(); + assertThat(uploadedImage).isNotNull(); + assertThat(uploadedImage.getWidth()).isEqualTo(1800); + assertThat(uploadedImage.getHeight()).isEqualTo(900); + } + @Test @DisplayName("빈 파일을 업로드하면 400을 반환한다") void uploadEmptyFileFails() throws Exception { @@ -241,4 +381,17 @@ private byte[] createPngBytes(int width, int height) throws Exception { return outputStream.toByteArray(); } } + + private byte[] createJpegBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "jpg", outputStream); + return outputStream.toByteArray(); + } + } + + private byte[] createWebpBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + return new AwtImage(image).bytes(WebpWriter.DEFAULT); + } } diff --git a/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java similarity index 98% rename from src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java rename to src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java index 4d2ab129..f4856d8a 100644 --- a/src/test/java/gg/agit/konect/unit/auth/AuthorizationInterceptorTest.java +++ b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.unit.auth; +package gg.agit.konect.unit.global.auth.web; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; diff --git a/src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java b/src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java new file mode 100644 index 00000000..dd55deaa --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java @@ -0,0 +1,137 @@ +package gg.agit.konect.unit.upload.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import com.sksamuel.scrimage.AwtImage; + +import gg.agit.konect.domain.upload.service.ImageConversionService; + +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; + +class ImageConversionServiceTest { + + private final ImageConversionService imageConversionService = new ImageConversionService(); + + @Test + @DisplayName("가로가 1800을 넘는 이미지는 비율을 유지한 채 1800 폭으로 축소한다") + void convertToWebPWhenWidthExceedsLimitResizesToMaxWidth() throws Exception { + byte[] originalBytes = createPngBytes(2160, 1080); + MockMultipartFile file = new MockMultipartFile( + "file", + "wide.png", + "image/png", + originalBytes + ); + + ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); + BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); + + assertThat(result.bytes()).isNotEmpty(); + assertThat(result.bytes()).isNotEqualTo(originalBytes); + assertThat(result.contentType()).isEqualTo("image/webp"); + assertThat(result.extension()).isEqualTo("webp"); + assertThat(convertedImage).isNotNull(); + assertThat(convertedImage.getWidth()).isEqualTo(1800); + assertThat(convertedImage.getHeight()).isEqualTo(900); + } + + @Test + @DisplayName("가로가 1800을 넘는 세로형 이미지는 비율을 유지한 채 축소한다") + void convertToWebPWhenPortraitWidthExceedsLimitResizesByRatio() throws Exception { + byte[] originalBytes = createPngBytes(2160, 4320); + MockMultipartFile file = new MockMultipartFile( + "file", + "portrait.png", + "image/png", + originalBytes + ); + + ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); + BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); + + assertThat(result.bytes()).isNotEmpty(); + assertThat(result.bytes()).isNotEqualTo(originalBytes); + assertThat(result.contentType()).isEqualTo("image/webp"); + assertThat(result.extension()).isEqualTo("webp"); + assertThat(convertedImage).isNotNull(); + assertThat(convertedImage.getWidth()).isEqualTo(1800); + assertThat(convertedImage.getHeight()).isEqualTo(3600); + } + + @Test + @DisplayName("가로가 1800을 넘는 webp 이미지는 비율을 유지한 채 1800 폭으로 축소한다") + void convertToWebPWhenWebpWidthExceedsLimitResizesToMaxWidth() throws Exception { + byte[] originalBytes = createWebpBytes(2160, 1080); + MockMultipartFile file = new MockMultipartFile( + "file", + "wide.webp", + "image/webp", + originalBytes + ); + + ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); + BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); + + assertThat(result.bytes()).isNotEmpty(); + assertThat(result.bytes()).isNotEqualTo(originalBytes); + assertThat(result.contentType()).isEqualTo("image/webp"); + assertThat(result.extension()).isEqualTo("webp"); + assertThat(convertedImage).isNotNull(); + assertThat(convertedImage.getWidth()).isEqualTo(1800); + assertThat(convertedImage.getHeight()).isEqualTo(900); + } + + @Test + @DisplayName("투명 PNG가 리사이즈 경로를 타도 알파 채널을 유지한다") + void convertToWebPWhenTransparentPngResizesPreservesAlpha() throws Exception { + byte[] originalBytes = createTransparentPngBytes(2160, 1080); + MockMultipartFile file = new MockMultipartFile( + "file", + "transparent.png", + "image/png", + originalBytes + ); + + ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); + BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); + + assertThat(convertedImage).isNotNull(); + assertThat(convertedImage.getColorModel().hasAlpha()).isTrue(); + assertThat(convertedImage.getWidth()).isEqualTo(1800); + assertThat(convertedImage.getHeight()).isEqualTo(900); + assertThat((convertedImage.getRGB(0, 0) >>> 24) & 0xff).isEqualTo(0); + } + + private byte[] createPngBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", outputStream); + return outputStream.toByteArray(); + } + } + + private byte[] createWebpBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + return new AwtImage(image).bytes(WebpWriter.DEFAULT); + } + + private byte[] createTransparentPngBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + image.setRGB(width / 2, height / 2, 0xFFFF0000); + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", outputStream); + return outputStream.toByteArray(); + } + } +} From 2914b7f98ba56f9a86a8e356ff2fda58db45b316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:38:33 +0900 Subject: [PATCH 14/55] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A7=95=20=EC=8B=9C=20=ED=80=84?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=201=20->=200.8=EB=A1=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/upload/service/ImageConversionService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java index 6cc54fca..06cfb6ed 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java @@ -31,7 +31,7 @@ public class ImageConversionService { private static final Set SKIP_CONVERSION_TYPES = Set.of("image/webp"); - private static final float DEFAULT_WEBP_QUALITY = 1.0f; + private static final float DEFAULT_WEBP_QUALITY = 0.8f; private static final int WEBP_QUALITY_PERCENT_SCALE = 100; private static final int MAX_UPLOAD_WIDTH = 1800; private static final int MAX_WEBP_DIMENSION = 16383; From c3d6730d05f7db39d27f92dee79855fd25adf290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:28:32 +0900 Subject: [PATCH 15/55] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A7=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#428)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 이미지 리사이징 로직 제거 * chore: 코드 포맷팅 --- build.gradle | 5 - .../service/ImageConversionService.java | 335 ------------------ .../domain/upload/service/UploadService.java | 32 +- .../domain/upload/UploadApiTest.java | 99 ++---- .../service/ImageConversionServiceTest.java | 137 ------- 5 files changed, 45 insertions(+), 563 deletions(-) delete mode 100644 src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java delete mode 100644 src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java diff --git a/build.gradle b/build.gradle index 6d8b818f..2ebbaea0 100644 --- a/build.gradle +++ b/build.gradle @@ -64,11 +64,6 @@ dependencies { implementation platform('software.amazon.awssdk:bom:2.41.14') implementation 'software.amazon.awssdk:s3' - // 이미지 WEBP 변환 - implementation 'com.sksamuel.scrimage:scrimage-core:4.3.2' - implementation 'com.sksamuel.scrimage:scrimage-webp:4.3.2' - implementation 'com.twelvemonkeys.imageio:imageio-jpeg:3.13.1' - // monitoring implementation 'io.micrometer:micrometer-registry-prometheus' diff --git a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java deleted file mode 100644 index 06cfb6ed..00000000 --- a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java +++ /dev/null @@ -1,335 +0,0 @@ -package gg.agit.konect.domain.upload.service; - -import java.awt.Graphics2D; -import java.awt.image.BufferedImage; -import java.io.IOException; -import java.io.InputStream; -import java.util.Iterator; -import java.util.Set; - -import javax.imageio.ImageIO; -import javax.imageio.ImageReadParam; -import javax.imageio.ImageReader; -import javax.imageio.metadata.IIOMetadata; -import javax.imageio.metadata.IIOMetadataNode; -import javax.imageio.stream.ImageInputStream; - -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import com.sksamuel.scrimage.AwtImage; -import com.sksamuel.scrimage.ImmutableImage; -import com.sksamuel.scrimage.webp.WebpWriter; - -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; -import lombok.extern.slf4j.Slf4j; - -@Service -@Slf4j -public class ImageConversionService { - - private static final Set SKIP_CONVERSION_TYPES = Set.of("image/webp"); - - private static final float DEFAULT_WEBP_QUALITY = 0.8f; - private static final int WEBP_QUALITY_PERCENT_SCALE = 100; - private static final int MAX_UPLOAD_WIDTH = 1800; - private static final int MAX_WEBP_DIMENSION = 16383; - - private static final int ORIENTATION_NORMAL = 1; - private static final int ORIENTATION_FLIP_HORIZONTAL = 2; - private static final int ORIENTATION_ROTATE_180 = 3; - private static final int ORIENTATION_FLIP_VERTICAL = 4; - private static final int ORIENTATION_ROTATE_90_FLIP = 5; - private static final int ORIENTATION_ROTATE_90 = 6; - private static final int ORIENTATION_ROTATE_270_FLIP = 7; - private static final int ORIENTATION_ROTATE_270 = 8; - - public ConversionResult convertToWebP(MultipartFile file) throws IOException { - String contentType = file.getContentType(); - boolean isWebp = contentType != null && SKIP_CONVERSION_TYPES.contains(contentType.toLowerCase()); - - if (isWebp) { - return normalizeWebp(file, contentType); - } - - try (InputStream input = file.getInputStream(); - ImageInputStream iis = ImageIO.createImageInputStream(input)) { - - Iterator readers = ImageIO.getImageReaders(iis); - if (!readers.hasNext()) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } - - ImageReader reader = readers.next(); - try { - // TwelveMonkeys reader requires the ImageInputStream to be bound explicitly - // before read/getImageMetadata; otherwise JPEG uploads can fail with getInput() == null. - reader.setInput(iis); - ImageReadParam readParam = reader.getDefaultReadParam(); - BufferedImage image = reader.read(0, readParam); - - if (image == null) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } - - int originalWidth = image.getWidth(); - int originalHeight = image.getHeight(); - image = applyExifOrientation(reader, image); - image = resizeToMaxWidthIfNeeded(image); - image = resizeForWebpIfNeeded(image); - - if (isWebp && originalWidth == image.getWidth() && originalHeight == image.getHeight()) { - log.debug("WEBP 이미지는 크기 변경이 없어 원본을 유지합니다: contentType={}", contentType); - return new ConversionResult(file.getBytes(), contentType, getExtension(contentType)); - } - - byte[] webpBytes = convertImageToWebP(image, DEFAULT_WEBP_QUALITY); - log.info("이미지 WEBP 변환 완료: 원본 {} bytes → WEBP {} bytes", file.getSize(), webpBytes.length); - - return new ConversionResult(webpBytes, "image/webp", "webp"); - } finally { - reader.dispose(); - } - } - } - - private ConversionResult normalizeWebp(MultipartFile file, String contentType) throws IOException { - BufferedImage image = ImmutableImage.loader().fromBytes(file.getBytes()).awt(); - if (image == null) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } - - int originalWidth = image.getWidth(); - int originalHeight = image.getHeight(); - image = resizeToMaxWidthIfNeeded(image); - image = resizeForWebpIfNeeded(image); - - if (originalWidth == image.getWidth() && originalHeight == image.getHeight()) { - log.debug("WEBP 이미지는 크기 변경이 없어 원본을 유지합니다: contentType={}", contentType); - return new ConversionResult(file.getBytes(), contentType, getExtension(contentType)); - } - - byte[] webpBytes = convertImageToWebP(image, DEFAULT_WEBP_QUALITY); - log.info("WEBP 이미지 크기 정규화 완료: 원본 {} bytes → WEBP {} bytes", file.getSize(), webpBytes.length); - return new ConversionResult(webpBytes, "image/webp", "webp"); - } - - private BufferedImage applyExifOrientation(ImageReader reader, BufferedImage image) { - try { - IIOMetadata metadata = reader.getImageMetadata(0); - int orientation = readExifOrientation(metadata); - if (orientation > ORIENTATION_NORMAL) { - log.debug("EXIF Orientation 적용: {}", orientation); - return rotateImage(image, orientation); - } - } catch (Exception e) { - log.debug("EXIF Orientation 읽기 실패, 원본 유지: {}", e.getMessage()); - } - return image; - } - - private int readExifOrientation(IIOMetadata metadata) { - for (String formatName : metadata.getMetadataFormatNames()) { - IIOMetadataNode root = (IIOMetadataNode)metadata.getAsTree(formatName); - Integer orientation = findOrientationInNode(root); - if (orientation != null) { - return orientation; - } - } - return 1; - } - - private Integer findOrientationInNode(IIOMetadataNode node) { - if ("exif".equalsIgnoreCase(node.getNodeName()) || "Orientation".equalsIgnoreCase(node.getNodeName())) { - String attr = node.getAttribute("value"); - if (attr.isEmpty()) { - attr = node.getAttribute("Orientation"); - } - if (!attr.isEmpty()) { - try { - return Integer.parseInt(attr); - } catch (NumberFormatException ignored) { - } - } - } - - String tagName = node.getAttribute("TagName"); - if ("Orientation".equals(tagName)) { - String attr = node.getAttribute("TagValue"); - if (attr != null && !attr.isEmpty()) { - try { - return Integer.parseInt(attr); - } catch (NumberFormatException ignored) { - } - } - } - - for (IIOMetadataNode child = (IIOMetadataNode)node.getFirstChild(); - child != null; - child = (IIOMetadataNode)child.getNextSibling()) { - Integer result = findOrientationInNode(child); - if (result != null) { - return result; - } - } - return null; - } - - private BufferedImage rotateImage(BufferedImage image, int orientation) { - return switch (orientation) { - case ORIENTATION_FLIP_HORIZONTAL -> flipHorizontal(image); - case ORIENTATION_ROTATE_180 -> rotate180(image); - case ORIENTATION_FLIP_VERTICAL -> flipVertical(image); - case ORIENTATION_ROTATE_90_FLIP -> flipHorizontal(rotate90(image)); - case ORIENTATION_ROTATE_90 -> rotate90(image); - case ORIENTATION_ROTATE_270_FLIP -> flipHorizontal(rotate270(image)); - case ORIENTATION_ROTATE_270 -> rotate270(image); - default -> image; - }; - } - - private BufferedImage rotate90(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage rotated = createCompatibleImage(image, h, w); - Graphics2D g = rotated.createGraphics(); - g.translate((h - w) / 2, (h - w) / 2); - g.rotate(Math.PI / 2, h / 2.0, w / 2.0); - g.drawRenderedImage(image, null); - g.dispose(); - return rotated; - } - - private BufferedImage rotate180(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage rotated = createCompatibleImage(image, w, h); - Graphics2D g = rotated.createGraphics(); - g.rotate(Math.PI, w / 2.0, h / 2.0); - g.drawRenderedImage(image, null); - g.dispose(); - return rotated; - } - - private BufferedImage rotate270(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage rotated = createCompatibleImage(image, h, w); - Graphics2D g = rotated.createGraphics(); - g.translate((h - w) / 2, (h - w) / 2); - g.rotate(-Math.PI / 2, h / 2.0, w / 2.0); - g.drawRenderedImage(image, null); - g.dispose(); - return rotated; - } - - private BufferedImage flipHorizontal(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage flipped = createCompatibleImage(image, w, h); - Graphics2D g = flipped.createGraphics(); - g.drawImage(image, w, 0, -w, h, null); - g.dispose(); - return flipped; - } - - private BufferedImage flipVertical(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage flipped = createCompatibleImage(image, w, h); - Graphics2D g = flipped.createGraphics(); - g.drawImage(image, 0, h, w, -h, null); - g.dispose(); - return flipped; - } - - private byte[] convertImageToWebP(BufferedImage image, float quality) throws IOException { - try { - return new AwtImage(image) - .bytes(WebpWriter.DEFAULT.withQ(toWebpQualityPercent(quality))); - } catch (RuntimeException e) { - throw new IOException("WEBP 이미지 변환에 실패했습니다.", e); - } - } - - private boolean exceedsWebpDimension(BufferedImage image) { - return image.getWidth() > MAX_WEBP_DIMENSION || image.getHeight() > MAX_WEBP_DIMENSION; - } - - private BufferedImage resizeToMaxWidthIfNeeded(BufferedImage image) { - if (image.getWidth() <= MAX_UPLOAD_WIDTH) { - return image; - } - - int resizedHeight = Math.max(1, - (int)Math.floor((double)image.getHeight() * MAX_UPLOAD_WIDTH / image.getWidth())); - return resizeImage(image, MAX_UPLOAD_WIDTH, resizedHeight, "업로드 최대 가로 길이에 맞게 이미지를 축소합니다"); - } - - private BufferedImage resizeForWebpIfNeeded(BufferedImage image) { - if (!exceedsWebpDimension(image)) { - return image; - } - - int originalWidth = image.getWidth(); - int originalHeight = image.getHeight(); - double scale = Math.min( - (double)MAX_WEBP_DIMENSION / originalWidth, - (double)MAX_WEBP_DIMENSION / originalHeight - ); - int resizedWidth = Math.max(1, (int)Math.floor(originalWidth * scale)); - int resizedHeight = Math.max(1, (int)Math.floor(originalHeight * scale)); - return resizeImage(image, resizedWidth, resizedHeight, "WebP 차원 제한에 맞게 이미지를 축소합니다"); - } - - private BufferedImage resizeImage(BufferedImage image, int resizedWidth, int resizedHeight, String logMessage) { - BufferedImage resized = createCompatibleImage(image, resizedWidth, resizedHeight); - Graphics2D g = resized.createGraphics(); - g.drawImage(image, 0, 0, resizedWidth, resizedHeight, null); - g.dispose(); - - log.info( - "{}: {}x{} -> {}x{}", - logMessage, - image.getWidth(), - image.getHeight(), - resizedWidth, - resizedHeight - ); - return resized; - } - - private BufferedImage createCompatibleImage(BufferedImage source, int width, int height) { - return new BufferedImage(width, height, resolveBufferedImageType(source)); - } - - private int resolveBufferedImageType(BufferedImage image) { - if (image.getType() != 0) { - return image.getType(); - } - return image.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB; - } - - private int toWebpQualityPercent(float quality) { - if (quality <= 0) { - return 0; - } - if (quality >= 1) { - return WEBP_QUALITY_PERCENT_SCALE; - } - return Math.round(quality * WEBP_QUALITY_PERCENT_SCALE); - } - - private String getExtension(String contentType) { - return switch (contentType.toLowerCase()) { - case "image/png" -> "png"; - case "image/jpg", "image/jpeg" -> "jpg"; - case "image/webp" -> "webp"; - default -> "bin"; - }; - } - - public record ConversionResult(byte[] bytes, String contentType, String extension) { - } -} diff --git a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java index d684ab2b..a7445a37 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.upload.service; import java.io.IOException; +import java.io.InputStream; import java.time.LocalDate; import java.util.Set; import java.util.UUID; @@ -10,7 +11,6 @@ import gg.agit.konect.domain.upload.dto.ImageUploadResponse; import gg.agit.konect.domain.upload.enums.UploadTarget; -import gg.agit.konect.domain.upload.service.ImageConversionService.ConversionResult; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.infrastructure.storage.cdn.StorageCdnProperties; @@ -38,30 +38,23 @@ public class UploadService { private final S3Client s3Client; private final S3StorageProperties s3StorageProperties; private final StorageCdnProperties storageCdnProperties; - private final ImageConversionService imageConversionService; public ImageUploadResponse uploadImage(MultipartFile file, UploadTarget target) { validateS3Configuration(); validateFile(file); - ConversionResult conversionResult; - try { - conversionResult = imageConversionService.convertToWebP(file); - } catch (IOException e) { - log.error("이미지 WEBP 변환 실패. fileName: {}", file.getOriginalFilename(), e); - throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); - } - - String key = buildKey(conversionResult.extension(), target); + String contentType = file.getContentType(); + String extension = getExtension(contentType); + String key = buildKey(extension, target); PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(s3StorageProperties.bucket()) .key(key) - .contentType(conversionResult.contentType()) + .contentType(contentType) .build(); - try { - s3Client.putObject(putObjectRequest, RequestBody.fromBytes(conversionResult.bytes())); + try (InputStream inputStream = file.getInputStream()) { + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); } catch (S3Exception e) { String awsErrorCode = e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : null; String awsErrorMessage = e.awsErrorDetails() != null ? e.awsErrorDetails().errorMessage() : e.getMessage(); @@ -77,7 +70,7 @@ public ImageUploadResponse uploadImage(MultipartFile file, UploadTarget target) e ); throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); - } catch (SdkClientException e) { + } catch (SdkClientException | IOException e) { log.error( "S3 업로드 클라이언트 오류(네트워크/자격증명/설정). bucket: {}, key: {}, message: {}", s3StorageProperties.bucket(), @@ -138,6 +131,15 @@ private String normalizePrefix(String keyPrefix) { return normalized; } + private String getExtension(String contentType) { + return switch (contentType.toLowerCase()) { + case "image/png" -> "png"; + case "image/jpg", "image/jpeg" -> "jpg"; + case "image/webp" -> "webp"; + default -> "bin"; + }; + } + private String trimTrailingSlash(String baseUrl) { if (baseUrl == null || baseUrl.isBlank()) { throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.cdn.base-url 설정이 필요합니다."); diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index 3b5b0cc2..050c59a1 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -26,9 +26,6 @@ import com.jayway.jsonpath.JsonPath; import com.google.auth.oauth2.GoogleCredentials; -import com.sksamuel.scrimage.AwtImage; -import com.sksamuel.scrimage.ImmutableImage; -import com.sksamuel.scrimage.webp.WebpWriter; import gg.agit.konect.domain.upload.enums.UploadTarget; import gg.agit.konect.support.IntegrationTestSupport; @@ -59,10 +56,11 @@ void setUp() throws Exception { class UploadImage { @Test - @DisplayName("지원하는 이미지를 업로드하면 webp key와 CDN URL을 반환한다") + @DisplayName("지원하는 이미지를 업로드하면 원본 확장자로 key와 CDN URL을 반환한다") void uploadImageSuccess() throws Exception { // given - MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + byte[] pngBytes = createPngBytes(8, 8); + MockMultipartFile file = imageFile("club.png", "image/png", pngBytes); // when MvcResult result = uploadImage(file, UploadTarget.CLUB) @@ -75,21 +73,22 @@ void uploadImageSuccess() throws Exception { String fileUrl = JsonPath.read(responseBody, "$.fileUrl"); assertThat(key).startsWith("test/club/"); - assertThat(key).endsWith(".webp"); + assertThat(key).endsWith(".png"); assertThat(fileUrl).isEqualTo("https://cdn.test.com/" + key); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); assertThat(requestCaptor.getValue().bucket()).isEqualTo("test-bucket"); assertThat(requestCaptor.getValue().key()).isEqualTo(key); - assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/png"); } @Test - @DisplayName("jpeg 이미지를 업로드하면 webp 로 변환해 저장한다") + @DisplayName("jpeg 이미지를 업로드하면 원본 형태로 저장한다") void uploadJpegImageSuccess() throws Exception { // given - MockMultipartFile file = imageFile("club.jpg", "image/jpeg", createJpegBytes(8, 8)); + byte[] jpegBytes = createJpegBytes(8, 8); + MockMultipartFile file = imageFile("club.jpg", "image/jpeg", jpegBytes); // when MvcResult result = uploadImage(file, UploadTarget.CLUB) @@ -101,18 +100,19 @@ void uploadJpegImageSuccess() throws Exception { String key = JsonPath.read(responseBody, "$.key"); assertThat(key).startsWith("test/club/"); - assertThat(key).endsWith(".webp"); + assertThat(key).endsWith(".jpg"); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); - assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/jpeg"); } @Test - @DisplayName("jpg content type 이미지를 업로드하면 webp 로 변환해 저장한다") + @DisplayName("jpg content type 이미지를 업로드하면 원본 형태로 저장한다") void uploadJpgContentTypeImageSuccess() throws Exception { // given - MockMultipartFile file = imageFile("club.jpg", "image/jpg", createJpegBytes(8, 8)); + byte[] jpegBytes = createJpegBytes(8, 8); + MockMultipartFile file = imageFile("club.jpg", "image/jpg", jpegBytes); // when MvcResult result = uploadImage(file, UploadTarget.CLUB) @@ -124,18 +124,18 @@ void uploadJpgContentTypeImageSuccess() throws Exception { String key = JsonPath.read(responseBody, "$.key"); assertThat(key).startsWith("test/club/"); - assertThat(key).endsWith(".webp"); + assertThat(key).endsWith(".jpg"); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); - assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/jpg"); } @Test - @DisplayName("webp 이미지를 업로드하면 변환 없이 그대로 저장한다") + @DisplayName("webp 이미지를 업로드하면 원본 형태로 저장한다") void uploadWebpImageSuccess() throws Exception { - // given - byte[] webpBytes = createWebpBytes(8, 8); + // given - webp 형태로 mock (실제 webp 변환 없이 단순 bytes로 처리) + byte[] webpBytes = new byte[] {0x52, 0x49, 0x46, 0x46}; // RIFF header mock MockMultipartFile file = imageFile("club.webp", "image/webp", webpBytes); // when @@ -151,71 +151,33 @@ void uploadWebpImageSuccess() throws Exception { assertThat(key).endsWith(".webp"); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); - verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture()); - assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream); - assertThat(outputStream.toByteArray()).isEqualTo(webpBytes); - } - - @Test - @DisplayName("가로가 1800을 넘는 webp 이미지는 비율 유지로 축소한 뒤 다시 webp 로 업로드한다") - void uploadWideWebpImageResizesAndKeepsWebp() throws Exception { - byte[] webpBytes = createWebpBytes(2160, 1080); - MockMultipartFile file = imageFile("wide.webp", "image/webp", webpBytes); - - MvcResult result = uploadImage(file, UploadTarget.CLUB) - .andExpect(status().isOk()) - .andReturn(); - - String responseBody = result.getResponse().getContentAsString(); - String key = JsonPath.read(responseBody, "$.key"); - - assertThat(key).startsWith("test/club/"); - assertThat(key).endsWith(".webp"); - - ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); - verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture()); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream); - BufferedImage uploadedImage = ImmutableImage.loader().fromBytes(outputStream.toByteArray()).awt(); - assertThat(uploadedImage).isNotNull(); - assertThat(uploadedImage.getWidth()).isEqualTo(1800); - assertThat(uploadedImage.getHeight()).isEqualTo(900); - assertThat(outputStream.toByteArray()).isNotEqualTo(webpBytes); } @Test - @DisplayName("가로가 1800을 넘는 이미지는 비율 유지로 축소한 뒤 webp 로 업로드한다") - void uploadWideImageResizesAndConvertsToWebP() throws Exception { - MockMultipartFile file = imageFile("wide.png", "image/png", createPngBytes(2160, 1080)); + @DisplayName("큰 이미지도 원본 형태로 업로드한다 (리사이징 없음)") + void uploadLargeImageWithoutResizing() throws Exception { + // given + byte[] pngBytes = createPngBytes(2160, 1080); + MockMultipartFile file = imageFile("wide.png", "image/png", pngBytes); + // when MvcResult result = uploadImage(file, UploadTarget.CLUB) .andExpect(status().isOk()) .andReturn(); + // then String responseBody = result.getResponse().getContentAsString(); String key = JsonPath.read(responseBody, "$.key"); assertThat(key).startsWith("test/club/"); - assertThat(key).endsWith(".webp"); + assertThat(key).endsWith(".png"); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture()); - assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - bodyCaptor.getValue().contentStreamProvider().newStream().transferTo(outputStream); - BufferedImage uploadedImage = ImmutableImage.loader().fromBytes(outputStream.toByteArray()).awt(); - assertThat(uploadedImage).isNotNull(); - assertThat(uploadedImage.getWidth()).isEqualTo(1800); - assertThat(uploadedImage.getHeight()).isEqualTo(900); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/png"); } @Test @@ -389,9 +351,4 @@ private byte[] createJpegBytes(int width, int height) throws Exception { return outputStream.toByteArray(); } } - - private byte[] createWebpBytes(int width, int height) throws Exception { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - return new AwtImage(image).bytes(WebpWriter.DEFAULT); - } } diff --git a/src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java b/src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java deleted file mode 100644 index dd55deaa..00000000 --- a/src/test/java/gg/agit/konect/unit/upload/service/ImageConversionServiceTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package gg.agit.konect.unit.upload.service; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; - -import javax.imageio.ImageIO; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockMultipartFile; - -import com.sksamuel.scrimage.AwtImage; - -import gg.agit.konect.domain.upload.service.ImageConversionService; - -import com.sksamuel.scrimage.ImmutableImage; -import com.sksamuel.scrimage.webp.WebpWriter; - -class ImageConversionServiceTest { - - private final ImageConversionService imageConversionService = new ImageConversionService(); - - @Test - @DisplayName("가로가 1800을 넘는 이미지는 비율을 유지한 채 1800 폭으로 축소한다") - void convertToWebPWhenWidthExceedsLimitResizesToMaxWidth() throws Exception { - byte[] originalBytes = createPngBytes(2160, 1080); - MockMultipartFile file = new MockMultipartFile( - "file", - "wide.png", - "image/png", - originalBytes - ); - - ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); - BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); - - assertThat(result.bytes()).isNotEmpty(); - assertThat(result.bytes()).isNotEqualTo(originalBytes); - assertThat(result.contentType()).isEqualTo("image/webp"); - assertThat(result.extension()).isEqualTo("webp"); - assertThat(convertedImage).isNotNull(); - assertThat(convertedImage.getWidth()).isEqualTo(1800); - assertThat(convertedImage.getHeight()).isEqualTo(900); - } - - @Test - @DisplayName("가로가 1800을 넘는 세로형 이미지는 비율을 유지한 채 축소한다") - void convertToWebPWhenPortraitWidthExceedsLimitResizesByRatio() throws Exception { - byte[] originalBytes = createPngBytes(2160, 4320); - MockMultipartFile file = new MockMultipartFile( - "file", - "portrait.png", - "image/png", - originalBytes - ); - - ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); - BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); - - assertThat(result.bytes()).isNotEmpty(); - assertThat(result.bytes()).isNotEqualTo(originalBytes); - assertThat(result.contentType()).isEqualTo("image/webp"); - assertThat(result.extension()).isEqualTo("webp"); - assertThat(convertedImage).isNotNull(); - assertThat(convertedImage.getWidth()).isEqualTo(1800); - assertThat(convertedImage.getHeight()).isEqualTo(3600); - } - - @Test - @DisplayName("가로가 1800을 넘는 webp 이미지는 비율을 유지한 채 1800 폭으로 축소한다") - void convertToWebPWhenWebpWidthExceedsLimitResizesToMaxWidth() throws Exception { - byte[] originalBytes = createWebpBytes(2160, 1080); - MockMultipartFile file = new MockMultipartFile( - "file", - "wide.webp", - "image/webp", - originalBytes - ); - - ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); - BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); - - assertThat(result.bytes()).isNotEmpty(); - assertThat(result.bytes()).isNotEqualTo(originalBytes); - assertThat(result.contentType()).isEqualTo("image/webp"); - assertThat(result.extension()).isEqualTo("webp"); - assertThat(convertedImage).isNotNull(); - assertThat(convertedImage.getWidth()).isEqualTo(1800); - assertThat(convertedImage.getHeight()).isEqualTo(900); - } - - @Test - @DisplayName("투명 PNG가 리사이즈 경로를 타도 알파 채널을 유지한다") - void convertToWebPWhenTransparentPngResizesPreservesAlpha() throws Exception { - byte[] originalBytes = createTransparentPngBytes(2160, 1080); - MockMultipartFile file = new MockMultipartFile( - "file", - "transparent.png", - "image/png", - originalBytes - ); - - ImageConversionService.ConversionResult result = imageConversionService.convertToWebP(file); - BufferedImage convertedImage = ImmutableImage.loader().fromBytes(result.bytes()).awt(); - - assertThat(convertedImage).isNotNull(); - assertThat(convertedImage.getColorModel().hasAlpha()).isTrue(); - assertThat(convertedImage.getWidth()).isEqualTo(1800); - assertThat(convertedImage.getHeight()).isEqualTo(900); - assertThat((convertedImage.getRGB(0, 0) >>> 24) & 0xff).isEqualTo(0); - } - - private byte[] createPngBytes(int width, int height) throws Exception { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - ImageIO.write(image, "png", outputStream); - return outputStream.toByteArray(); - } - } - - private byte[] createWebpBytes(int width, int height) throws Exception { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - return new AwtImage(image).bytes(WebpWriter.DEFAULT); - } - - private byte[] createTransparentPngBytes(int width, int height) throws Exception { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - image.setRGB(width / 2, height / 2, 0xFFFF0000); - - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - ImageIO.write(image, "png", outputStream); - return outputStream.toByteArray(); - } - } -} From 58279bea8627d05e8923fdc0a9eb2efdef3a8dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:47:07 +0900 Subject: [PATCH 16/55] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20(#429)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 관리자 1:1 채팅방 조회 성능 최적화 * refactor: 서브쿼리 제거 * feat: 인덱스 추가 * chore: 코드 포맷팅 --- .../chat/dto/AdminChatRoomProjection.java | 18 ++++++ .../chat/repository/ChatRoomRepository.java | 46 ++++++++++++++ .../domain/chat/service/ChatService.java | 61 ++++--------------- .../V57__add_chat_admin_query_indexes.sql | 10 +++ 4 files changed, 86 insertions(+), 49 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java create mode 100644 src/main/resources/db/migration/V57__add_chat_admin_query_indexes.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java new file mode 100644 index 00000000..d6591396 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java @@ -0,0 +1,18 @@ +package gg.agit.konect.domain.chat.dto; + +import java.time.LocalDateTime; + +/** + * 관리자용 1:1 채팅방 목록 조회를 위한 Projection DTO + * 필드 순서와 타입이 JPQL SELECT 절과 정확히 일치해야 합니다. + */ +public record AdminChatRoomProjection( + Integer roomId, + String lastMessage, + LocalDateTime lastSentAt, + Integer nonAdminUserId, + String nonAdminUserName, + String nonAdminImageUrl, + Long unreadCount +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index c1a30891..6cc9f7f0 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; +import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.user.enums.UserRole; @@ -114,4 +115,49 @@ List findAllSystemAdminDirectRooms( @Param("systemAdminId") Integer systemAdminId, @Param("adminRole") UserRole adminRole ); + + /** + * 관리자용 1:1 채팅방 목록을 Projection DTO로 최적화 조회 + *

+ * 사용자가 응답한 채팅방만 필터링하고, 필요한 필드만 한 번에 조회합니다. + * 이 메소드는 다음과 같은 최적화를 제공합니다: + *

    + *
  • ChatRoom 엔티티 전체 로딩 대신 필요한 필드만 Projection
  • + *
  • 읽지 않은 메시지 수를 DB에서 직접 계산 (COUNT 서브쿼리)
  • + *
  • 상대방 사용자 정보를 JOIN으로 한 번에 조회
  • + *
+ */ + @Query(""" + SELECT new gg.agit.konect.domain.chat.dto.AdminChatRoomProjection( + cr.id, + cr.lastMessageContent, + cr.lastMessageSentAt, + u.id, + u.name, + u.imageUrl, + COUNT(cm) + ) + FROM ChatRoom cr + JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id + JOIN User u ON u.id = crm.id.userId + JOIN ChatRoomMember adminCrm ON adminCrm.id.chatRoomId = cr.id + AND adminCrm.id.userId = :systemAdminId + LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id + AND cm.sender.id <> :systemAdminId + AND cm.createdAt > adminCrm.lastReadAt + WHERE cr.club IS NULL + AND u.role != :adminRole + AND EXISTS ( + SELECT 1 FROM ChatMessage userReply + JOIN userReply.sender userSender + WHERE userReply.chatRoom.id = cr.id + AND userSender.role != :adminRole + ) + GROUP BY cr.id, cr.lastMessageContent, cr.lastMessageSentAt, u.id, u.name, u.imageUrl + ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id + """) + List findAdminChatRoomsOptimized( + @Param("systemAdminId") Integer systemAdminId, + @Param("adminRole") UserRole adminRole + ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 21f9b463..da1b7cbc 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -9,7 +9,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -30,6 +29,7 @@ import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.dto.UnreadMessageCount; import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.event.AdminChatReceivedEvent; @@ -277,59 +277,22 @@ private List getDirectChatRooms(Integer userId) { } private List getAdminDirectChatRooms() { - List roomSummaries = new ArrayList<>(); - - List adminUserRooms = chatRoomRepository.findAllSystemAdminDirectRooms( + List projections = chatRoomRepository.findAdminChatRoomsOptimized( SYSTEM_ADMIN_ID, UserRole.ADMIN ); - List roomIds = extractChatRoomIds(adminUserRooms); - Map> roomMemberInfoMap = getRoomMemberInfoMap(adminUserRooms); - Map adminUnreadCountMap = getAdminUnreadCountMap(roomIds); - Set repliedRoomIds = roomIds.isEmpty() - ? Set.of() - : new HashSet<>(chatMessageRepository.findRoomIdsWithUserReplyByRoomIds(roomIds, UserRole.ADMIN)); - - List allUserIds = roomMemberInfoMap.values().stream() - .flatMap(List::stream) - .map(MemberInfo::userId) - .distinct() - .toList(); - - Map userMap = allUserIds.isEmpty() - ? Map.of() - : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, user -> user)); - for (ChatRoom chatRoom : adminUserRooms) { - List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); - User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); - if (nonAdminUser == null) { - continue; - } - if (!repliedRoomIds.contains(chatRoom.getId())) { - continue; - } - - roomSummaries.add(new ChatRoomSummaryResponse( - chatRoom.getId(), + return projections.stream() + .map(projection -> new ChatRoomSummaryResponse( + projection.roomId(), ChatType.DIRECT, - nonAdminUser.getName(), - nonAdminUser.getImageUrl(), - chatRoom.getLastMessageContent(), - chatRoom.getLastMessageSentAt(), - adminUnreadCountMap.getOrDefault(chatRoom.getId(), 0), + projection.nonAdminUserName(), + projection.nonAdminImageUrl(), + projection.lastMessage(), + projection.lastSentAt(), + projection.unreadCount().intValue(), false - )); - } - - roomSummaries.sort(Comparator - .comparing( - ChatRoomSummaryResponse::lastSentAt, - Comparator.nullsLast(Comparator.reverseOrder()) - ) - .thenComparing(ChatRoomSummaryResponse::roomId)); - - return roomSummaries; + )) + .toList(); } private ChatMessagePageResponse getDirectChatRoomMessages( diff --git a/src/main/resources/db/migration/V57__add_chat_admin_query_indexes.sql b/src/main/resources/db/migration/V57__add_chat_admin_query_indexes.sql new file mode 100644 index 00000000..ae235662 --- /dev/null +++ b/src/main/resources/db/migration/V57__add_chat_admin_query_indexes.sql @@ -0,0 +1,10 @@ +-- 관리자 1:1 채팅방 조회 쿼리 최적화를 위한 인덱스 추가 +-- findAdminChatRoomsOptimized() 메소드 성능 개선 + +-- 1. 관리자 응답 여부 확인 및 unread count 계산용 +CREATE INDEX idx_chat_message_room_sender + ON chat_message (chat_room_id, sender_id); + +-- 2. last_message_sent_at 정렬 최적화 +CREATE INDEX idx_chat_room_last_message + ON chat_room (club_id, last_message_sent_at DESC); From 4e773cd8c707223f738d23f9d2b193eeffc6ab9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:04:08 +0900 Subject: [PATCH 17/55] =?UTF-8?q?feat:=20APM=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8B=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?(#430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 트레이싱 로깅 설정 * feat: 애플리케이션 이름 추가 * feat: 트레이싱을 위한 자바 에이전트 추가 * feat: 환경변수 예시 추가 --- .env.example | 11 ++++++++++- Dockerfile | 8 ++++++-- .../konect/global/logging/RequestLoggingFilter.java | 5 ++++- src/main/resources/application.yml | 2 ++ src/main/resources/logback-spring.xml | 4 ++-- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 576c1aac..056fd20e 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ SESSION_COOKIE_DOMAIN=localhost ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 # Database Configuration -MYSQL_URL=jdbc:mysql://localhost:3306/konect?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 +MYSQL_URL='jdbc:mysql://localhost:3306/konect?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8' MYSQL_PORT=3306 MYSQL_DATABASE=konect MYSQL_ROOT_PASSWORD=your-mysql-root-password @@ -58,3 +58,12 @@ CLAUDE_MODEL=claude-sonnet-4-20250514 # MCP Bridge Configuration MCP_BRIDGE_URL=http://localhost:3100 + +# Tracing Configuration (OTel Java Agent -> Tempo) +JAVA_TOOL_OPTIONS=-javaagent:/app/opentelemetry-javaagent.jar +OTEL_SERVICE_NAME=your-service-name +OTEL_TRACES_EXPORTER=otlp +OTEL_METRICS_EXPORTER=none +OTEL_LOGS_EXPORTER=none +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://your-monitoring-host:4318/v1/traces diff --git a/Dockerfile b/Dockerfile index c2d63a74..97fa9391 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ FROM amazoncorretto:17-alpine +ARG OTEL_JAVA_AGENT_VERSION=2.18.1 + WORKDIR /app RUN addgroup -S konect && adduser -S konect -G konect COPY build/libs/KONECT_API.jar KONECT_API.jar -RUN chown -R konect:konect KONECT_API.jar +RUN wget -O opentelemetry-javaagent.jar "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_JAVA_AGENT_VERSION}/opentelemetry-javaagent.jar" + +RUN chown -R konect:konect /app USER konect:konect @@ -15,4 +19,4 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 -ENTRYPOINT ["java", "-jar", "KONECT_API.jar"] +ENTRYPOINT ["sh", "-c", "java $JAVA_TOOL_OPTIONS -jar KONECT_API.jar"] diff --git a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java index cab5f5d2..24faaf30 100644 --- a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java +++ b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java @@ -6,6 +6,8 @@ import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.util.PathMatcher; @@ -24,6 +26,7 @@ @Slf4j @Component +@Order(Ordered.LOWEST_PRECEDENCE) @RequiredArgsConstructor public class RequestLoggingFilter extends OncePerRequestFilter { @@ -58,7 +61,7 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res stopWatch.stop(); log.info("request end [requestId: {}, uri: {} {}, time: {}ms, status: {}]", requestId, method, uri, stopWatch.getTotalTimeMillis(), cachedResponse.getStatus()); - MDC.clear(); + MDC.remove(REQUEST_ID); cachedResponse.copyBodyToResponse(); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 25a48824..415cce7e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + application: + name: konect-backend config: import: - classpath:application-db.yml diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 2e8368ae..c04ca581 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -9,7 +9,7 @@ - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %clr(%-5level) %clr(${PID:-}){magenta} %clr(---){faint} %clr([%15.15thread]){faint} %clr(%-40.40logger{36}){cyan} : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %clr(%-5level) %clr(${PID:-}){magenta} %clr(---){faint} %clr([%15.15thread]){faint} %clr([trace=%X{trace_id:-}%X{traceId:-} span=%X{span_id:-}%X{spanId:-} request=%X{requestId:-}]){yellow} %clr(%-40.40logger{36}){cyan} : %msg%n @@ -22,7 +22,7 @@ 3GB - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level ${PID:-} --- [%15.15thread] %-40.40logger{36} : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level ${PID:-} --- [%15.15thread] [trace=%X{trace_id:-}%X{traceId:-} span=%X{span_id:-}%X{spanId:-} request=%X{requestId:-}] %-40.40logger{36} : %msg%n From 663f48a6ed73c1ac7f9ca76fdd4a5bb7b8b68122 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:51:43 +0900 Subject: [PATCH 18/55] =?UTF-8?q?feat:=20Google=20Sheets=20API=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: CAM-233 Google Sheets API 리팩토링 * refactor: CAM-233 coderabbitai/copilot 리뷰 반영 * fix: GoogleDriveOAuthService - @Transactional self-invocation 문제 수정 * fix: ClubSheetIdUpdateRequest, SheetImportRequest - @Pattern 강화 * fix: SheetMigrationService - copyTemplate RuntimeException을 CustomException으로 변경 * fix: GoogleDriveOAuthService - code/state 선검증 추가 및 persistGoogleRefreshToken private 변경 * fix: SheetMigrationService - extractSpreadsheetId를 SpreadsheetUrlParser.extractId로 교체 --- .../ClubSheetMigrationController.java | 2 +- .../club/dto/ClubSheetIdUpdateRequest.java | 12 +- .../domain/club/dto/SheetImportRequest.java | 10 +- .../club/event/ClubMemberChangedEvent.java | 9 - .../club/service/ClubApplicationService.java | 2 - .../service/ClubMemberManagementService.java | 9 - .../club/service/ClubMemberSheetService.java | 20 +- .../club/service/SheetImportService.java | 4 +- .../club/service/SheetMigrationService.java | 88 ++++----- .../club/service/SheetSyncDebouncer.java | 49 ----- .../club/service/SpreadsheetUrlParser.java | 24 +++ .../domain/user/model/UserOAuthAccount.java | 7 + .../konect/global/code/ApiResponseCode.java | 3 + .../konect/global/config/SecurityPaths.java | 3 +- .../googlesheets/GoogleSheetsConfig.java | 21 +++ .../googlesheets/GoogleSheetsProperties.java | 8 +- .../oauth/GoogleDriveOAuthController.java | 38 ++++ .../oauth/GoogleDriveOAuthService.java | 172 ++++++++++++++++++ .../resources/application-infrastructure.yml | 3 + ...ve_refresh_token_to_user_oauth_account.sql | 2 + 20 files changed, 339 insertions(+), 147 deletions(-) delete mode 100644 src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SpreadsheetUrlParser.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java create mode 100644 src/main/resources/db/migration/V58__add_google_drive_refresh_token_to_user_oauth_account.sql diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java index ffde170c..c049e40e 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -43,7 +43,7 @@ public ResponseEntity importPreMembers( @UserId Integer requesterId ) { int count = sheetImportService.importPreMembersFromSheet( - clubId, requesterId, request.spreadsheetId() + clubId, requesterId, request.spreadsheetUrl() ); return ResponseEntity.ok(SheetImportResponse.of(count)); } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java index 51a34e92..9072b32c 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java @@ -5,15 +5,15 @@ import jakarta.validation.constraints.Pattern; public record ClubSheetIdUpdateRequest( - @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @NotBlank(message = "스프레드시트 URL은 필수 입력입니다.") @Pattern( - regexp = "^[A-Za-z0-9_-]+$", - message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + regexp = "^https://docs\\.google\\.com/spreadsheets/(?:u/\\d+/)?d/[A-Za-z0-9_-]+.*", + message = "유효한 구글 스프레드시트 URL을 입력해주세요." ) @Schema( - description = "등록할 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", - example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + description = "등록할 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" ) - String spreadsheetId + String spreadsheetUrl ) { } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java index 1f733420..c7110b63 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java @@ -7,13 +7,13 @@ public record SheetImportRequest( @NotBlank @Pattern( - regexp = "^[A-Za-z0-9_-]+$", - message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + regexp = "^https://docs\\.google\\.com/spreadsheets/(?:u/\\d+/)?d/[A-Za-z0-9_-]+.*", + message = "유효한 구글 스프레드시트 URL을 입력해주세요." ) @Schema( - description = "인명부가 담긴 구글 스프레드시트 ID", - example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + description = "인명부가 담긴 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" ) - String spreadsheetId + String spreadsheetUrl ) { } diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java deleted file mode 100644 index 2afa0b67..00000000 --- a/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java +++ /dev/null @@ -1,9 +0,0 @@ -package gg.agit.konect.domain.club.event; - -public record ClubMemberChangedEvent( - Integer clubId -) { - public static ClubMemberChangedEvent of(Integer clubId) { - return new ClubMemberChangedEvent(clubId); - } -} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 9cc2a9d3..076d7c69 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -31,7 +31,6 @@ import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; -import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubApply; import gg.agit.konect.domain.club.model.ClubApplyAnswer; @@ -252,7 +251,6 @@ public void approveClubApplication(Integer clubId, Integer applicationId, Intege clubId, club.getName() )); - applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } @Transactional diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 7c973d13..5a83be74 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Optional; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,7 +18,6 @@ import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; import gg.agit.konect.domain.club.enums.ClubPosition; -import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubPreMember; @@ -44,7 +42,6 @@ public class ClubMemberManagementService { private final ClubPermissionValidator clubPermissionValidator; private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; - private final ApplicationEventPublisher applicationEventPublisher; @Transactional public ClubMember changeMemberPosition( @@ -75,7 +72,6 @@ public ClubMember changeMemberPosition( validatePositionLimit(clubId, newPosition, target); target.changePosition(newPosition); - applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return target; } @@ -134,7 +130,6 @@ private ClubPreMemberAddResponse addDirectMember(Club club, User user, ClubPosit ClubMember savedMember = clubMemberRepository.save(clubMember); chatRoomMembershipService.addClubMember(savedMember); - applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(club.getId())); return ClubPreMemberAddResponse.from(savedMember); } @@ -199,7 +194,6 @@ public List transferPresident( currentPresident.changePosition(MEMBER); newPresident.changePosition(PRESIDENT); - applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return List.of(currentPresident, newPresident); } @@ -229,7 +223,6 @@ public List changeVicePresident( ClubMember currentVicePresident = currentVicePresidentOpt.get(); currentVicePresident.changePosition(MEMBER); changedMembers.add(currentVicePresident); - applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } return changedMembers; } @@ -248,7 +241,6 @@ public List changeVicePresident( newVicePresident.changePosition(VICE_PRESIDENT); changedMembers.add(newVicePresident); - applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return changedMembers; } @@ -281,7 +273,6 @@ public void removeMember(Integer clubId, Integer targetUserId, Integer requester clubMemberRepository.delete(target); chatRoomMembershipService.removeClubMember(clubId, targetUserId); - applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } private void validateNotSelf(Integer userId1, Integer userId2, ApiResponseCode errorCode) { diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 6485f8ae..222f8d88 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -4,8 +4,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -13,7 +11,6 @@ import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; -import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; @@ -29,24 +26,23 @@ public class ClubMemberSheetService { private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; private final ClubPermissionValidator clubPermissionValidator; - private final SheetSyncDebouncer sheetSyncDebouncer; private final SheetSyncExecutor sheetSyncExecutor; private final SheetHeaderMapper sheetHeaderMapper; private final ObjectMapper objectMapper; - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void onClubMemberChanged(ClubMemberChangedEvent event) { - sheetSyncDebouncer.debounce(event.clubId()); - } - @Transactional public void updateSheetId( Integer clubId, Integer requesterId, ClubSheetIdUpdateRequest request ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = SpreadsheetUrlParser.extractId(request.spreadsheetUrl()); + SheetHeaderMapper.SheetAnalysisResult result = - sheetHeaderMapper.analyzeAllSheets(request.spreadsheetId()); + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); String mappingJson = null; try { mappingJson = objectMapper.writeValueAsString(result.memberListMapping().toMap()); @@ -54,9 +50,7 @@ public void updateSheetId( log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); } - Club club = clubRepository.getById(clubId); - clubPermissionValidator.validateManagerAccess(clubId, requesterId); - club.updateGoogleSheetId(request.spreadsheetId()); + club.updateGoogleSheetId(spreadsheetId); if (mappingJson != null) { club.updateSheetColumnMapping(mappingJson); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 7ab32742..108f0a31 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -33,11 +33,13 @@ public class SheetImportService { public int importPreMembersFromSheet( Integer clubId, Integer requesterId, - String spreadsheetId + String spreadsheetUrl ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); Club club = clubRepository.getById(clubId); + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); SheetColumnMapping mapping = analysis.memberListMapping(); diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index 1dcd17ee..c418f55c 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.club.service; import java.io.IOException; +import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -12,20 +13,22 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.databind.ObjectMapper; + import com.google.api.services.drive.Drive; import com.google.api.services.drive.model.File; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; -import com.google.api.services.drive.model.Permission; - import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubRepository; -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,8 +39,6 @@ public class SheetMigrationService { private static final Pattern FOLDER_ID_PATTERN = Pattern.compile("(?:folders/|id=)([a-zA-Z0-9_-]{20,})"); - private static final Pattern SPREADSHEET_ID_PATTERN = - Pattern.compile("/spreadsheets/d/([a-zA-Z0-9_-]+)"); private static final String MIME_TYPE_SPREADSHEET = "application/vnd.google-apps.spreadsheet"; private static final String NEW_SHEET_TITLE_PREFIX = "KONECT_인명부_"; @@ -45,12 +46,13 @@ public class SheetMigrationService { @Value("${google.sheets.template-spreadsheet-id:}") private String defaultTemplateSpreadsheetId; - private final Drive googleDriveService; private final Sheets googleSheetsService; private final SheetHeaderMapper sheetHeaderMapper; private final ClubRepository clubRepository; - private final UserRepository userRepository; + private final UserOAuthAccountRepository userOAuthAccountRepository; private final ClubPermissionValidator clubPermissionValidator; + private final GoogleSheetsConfig googleSheetsConfig; + private final ObjectMapper objectMapper; @Transactional public String migrateToTemplate( @@ -59,18 +61,34 @@ public String migrateToTemplate( String sourceSpreadsheetUrl ) { Club club = clubRepository.getById(clubId); - User requester = userRepository.getById(requesterId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); - String templateId = defaultTemplateSpreadsheetId; + String templateId = defaultTemplateSpreadsheetId; if (templateId == null || templateId.isBlank()) { throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID); } - String sourceSpreadsheetId = extractSpreadsheetId(sourceSpreadsheetUrl); - String folderId = resolveFolderId(sourceSpreadsheetUrl, sourceSpreadsheetId); + UserOAuthAccount oauthAccount = userOAuthAccountRepository + .findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH)); - String newSpreadsheetId = copyTemplate(templateId, club.getName(), folderId, requester.getEmail()); + String driveRefreshToken = oauthAccount.getGoogleDriveRefreshToken(); + if (driveRefreshToken == null || driveRefreshToken.isBlank()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + } + + Drive userDriveService; + try { + userDriveService = googleSheetsConfig.buildUserDriveService(driveRefreshToken); + } catch (IOException | GeneralSecurityException e) { + log.error("Failed to build user Drive service. requesterId={}", requesterId, e); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + + String sourceSpreadsheetId = SpreadsheetUrlParser.extractId(sourceSpreadsheetUrl); + String folderId = resolveFolderId(userDriveService, sourceSpreadsheetUrl, sourceSpreadsheetId); + + String newSpreadsheetId = copyTemplate(userDriveService, templateId, club.getName(), folderId); SheetHeaderMapper.SheetAnalysisResult sourceAnalysis = sheetHeaderMapper.analyzeAllSheets(sourceSpreadsheetId); @@ -90,10 +108,8 @@ public String migrateToTemplate( SheetHeaderMapper.SheetAnalysisResult newAnalysis = sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); try { - com.fasterxml.jackson.databind.ObjectMapper om = - new com.fasterxml.jackson.databind.ObjectMapper(); club.updateSheetColumnMapping( - om.writeValueAsString(newAnalysis.memberListMapping().toMap()) + objectMapper.writeValueAsString(newAnalysis.memberListMapping().toMap()) ); } catch (Exception e) { log.warn("Failed to serialize new mapping. cause={}", e.getMessage()); @@ -107,21 +123,13 @@ public String migrateToTemplate( return newSpreadsheetId; } - private String extractSpreadsheetId(String url) { - Matcher m = SPREADSHEET_ID_PATTERN.matcher(url); - if (m.find()) { - return m.group(1); - } - return url; - } - - private String resolveFolderId(String url, String spreadsheetId) { + private String resolveFolderId(Drive driveService, String url, String spreadsheetId) { Matcher m = FOLDER_ID_PATTERN.matcher(url); if (m.find()) { return m.group(1); } try { - File file = googleDriveService.files().get(spreadsheetId) + File file = driveService.files().get(spreadsheetId) .setFields("parents") .execute(); List parents = file.getParents(); @@ -134,7 +142,7 @@ private String resolveFolderId(String url, String spreadsheetId) { return null; } - private String copyTemplate(String templateId, String clubName, String targetFolderId, String ownerEmail) { + private String copyTemplate(Drive driveService, String templateId, String clubName, String targetFolderId) { try { String title = NEW_SHEET_TITLE_PREFIX + clubName; File copyMetadata = new File().setName(title); @@ -143,36 +151,16 @@ private String copyTemplate(String templateId, String clubName, String targetFol copyMetadata.setParents(Collections.singletonList(targetFolderId)); } - File copied = googleDriveService.files().copy(templateId, copyMetadata) + File copied = driveService.files().copy(templateId, copyMetadata) .setFields("id") .execute(); - log.info("Template copied. newId={}, folderId={}", copied.getId(), targetFolderId); - - transferOwnership(copied.getId(), ownerEmail); - + log.info("Template copied by user. newId={}, folderId={}", copied.getId(), targetFolderId); return copied.getId(); } catch (IOException e) { log.error("Failed to copy template. cause={}", e.getMessage(), e); - throw new RuntimeException("Failed to copy template spreadsheet", e); - } - } - - private void transferOwnership(String fileId, String ownerEmail) { - try { - Permission permission = new Permission() - .setType("user") - .setRole("owner") - .setEmailAddress(ownerEmail); - - googleDriveService.permissions().create(fileId, permission) - .setTransferOwnership(true) - .execute(); - - log.info("Ownership transferred. fileId={}, ownerEmail={}", fileId, ownerEmail); - } catch (IOException e) { - log.warn("Failed to transfer ownership. fileId={}, cause={}", fileId, e.getMessage()); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java deleted file mode 100644 index a80dadda..00000000 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java +++ /dev/null @@ -1,49 +0,0 @@ -package gg.agit.konect.domain.club.service; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -import jakarta.annotation.PreDestroy; - -import org.springframework.stereotype.Component; - -import gg.agit.konect.domain.club.enums.ClubSheetSortKey; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class SheetSyncDebouncer { - - private static final long DEBOUNCE_DELAY_SECONDS = 3; - - private final ConcurrentHashMap> pendingTasks = - new ConcurrentHashMap<>(); - private final ScheduledExecutorService scheduler = - Executors.newSingleThreadScheduledExecutor(); - - private final SheetSyncExecutor sheetSyncExecutor; - - @PreDestroy - public void shutdown() { - scheduler.shutdown(); - log.info("SheetSyncDebouncer scheduler shutdown."); - } - - public void debounce(Integer clubId) { - pendingTasks.compute(clubId, (id, existing) -> { - if (existing != null && !existing.isDone()) { - existing.cancel(false); - log.debug("Sheet sync debounced. clubId={}", id); - } - return scheduler.schedule(() -> { - pendingTasks.remove(id); - sheetSyncExecutor.executeWithSort(id, ClubSheetSortKey.POSITION, true); - }, DEBOUNCE_DELAY_SECONDS, TimeUnit.SECONDS); - }); - } -} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SpreadsheetUrlParser.java b/src/main/java/gg/agit/konect/domain/club/service/SpreadsheetUrlParser.java new file mode 100644 index 00000000..b86a4c33 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SpreadsheetUrlParser.java @@ -0,0 +1,24 @@ +package gg.agit.konect.domain.club.service; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public final class SpreadsheetUrlParser { + + private static final Pattern SPREADSHEET_ID_PATTERN = + Pattern.compile("/spreadsheets/(?:u/\\d+/)?d/([a-zA-Z0-9_-]+)"); + + private SpreadsheetUrlParser() { + } + + public static String extractId(String url) { + Matcher m = SPREADSHEET_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } +} diff --git a/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java b/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java index 77031872..8e051d5d 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java +++ b/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java @@ -60,6 +60,9 @@ public class UserOAuthAccount extends BaseEntity { @Column(name = "apple_refresh_token", length = 1024) private String appleRefreshToken; + @Column(name = "google_drive_refresh_token", length = 1024) + private String googleDriveRefreshToken; + @Builder private UserOAuthAccount(User user, Provider provider, String providerId, String oauthEmail, String appleRefreshToken) { @@ -92,4 +95,8 @@ public void updateProviderId(String providerId) { public void updateAppleRefreshToken(String appleRefreshToken) { this.appleRefreshToken = appleRefreshToken; } + + public void updateGoogleDriveRefreshToken(String googleDriveRefreshToken) { + this.googleDriveRefreshToken = googleDriveRefreshToken; + } } diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index aa07002c..fe3750fb 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -93,6 +93,7 @@ public enum ApiResponseCode { NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), + NOT_FOUND_GOOGLE_DRIVE_AUTH(HttpStatus.NOT_FOUND, "Google Drive 권한이 연결되지 않았습니다. 먼저 Drive 권한을 연결해 주세요."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), @@ -121,6 +122,8 @@ public enum ApiResponseCode { CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), FAILED_SYNC_GOOGLE_SHEET(HttpStatus.INTERNAL_SERVER_ERROR, "구글 스프레드시트 동기화에 실패했습니다."), + FAILED_INIT_GOOGLE_DRIVE(HttpStatus.INTERNAL_SERVER_ERROR, "Google Drive 서비스 초기화에 실패했습니다."), + FAILED_GOOGLE_DRIVE_AUTH(HttpStatus.INTERNAL_SERVER_ERROR, "Google Drive 인증 코드 교환에 실패했습니다."), FAILED_SEND_NOTIFICATION(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다."), UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다."); diff --git a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java index 74dee66f..814b02cf 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -10,7 +10,8 @@ public final class SecurityPaths { "/v3/api-docs/**", "/swagger-resources/**", "/error", - "/slack/events" + "/slack/events", + "/auth/oauth/google/drive/callback" }; public static final String[] DENY_PATHS = {}; diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index 8467c3c9..bb85274b 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -5,6 +5,7 @@ import java.io.InputStream; import java.security.GeneralSecurityException; import java.util.Arrays; +import java.util.Collections; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -17,6 +18,7 @@ import com.google.api.services.sheets.v4.SheetsScopes; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.UserCredentials; import lombok.RequiredArgsConstructor; @@ -60,4 +62,23 @@ public Drive googleDriveService( .setApplicationName(googleSheetsProperties.applicationName()) .build(); } + + public Drive buildUserDriveService(String refreshToken) throws IOException, GeneralSecurityException { + UserCredentials credentials = UserCredentials.newBuilder() + .setClientId(googleSheetsProperties.oauthClientId()) + .setClientSecret(googleSheetsProperties.oauthClientSecret()) + .setRefreshToken(refreshToken) + .build(); + + GoogleCredentials scoped = credentials.createScoped( + Collections.singletonList(DriveScopes.DRIVE) + ); + + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(scoped)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } } diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java index b8cd5882..3abce5b2 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java @@ -1,10 +1,16 @@ package gg.agit.konect.infrastructure.googlesheets; +import jakarta.validation.constraints.NotBlank; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +@Validated @ConfigurationProperties(prefix = "google.sheets") public record GoogleSheetsProperties( String credentialsPath, - String applicationName + String applicationName, + @NotBlank String oauthClientId, + @NotBlank String oauthClientSecret, + @NotBlank String oauthCallbackBaseUrl ) { } diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java new file mode 100644 index 00000000..bb5485a4 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java @@ -0,0 +1,38 @@ +package gg.agit.konect.infrastructure.oauth; + +import java.net.URI; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.global.auth.annotation.PublicApi; +import gg.agit.konect.global.auth.annotation.UserId; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth/oauth/google/drive") +public class GoogleDriveOAuthController { + + private final GoogleDriveOAuthService googleDriveOAuthService; + + @GetMapping("/authorize") + public ResponseEntity authorize(@UserId Integer userId) { + String authUrl = googleDriveOAuthService.buildAuthorizationUrl(userId); + return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(authUrl)).build(); + } + + @PublicApi + @GetMapping("/callback") + public ResponseEntity callback( + @RequestParam("code") String code, + @RequestParam("state") String state + ) { + googleDriveOAuthService.exchangeAndSaveToken(code, state); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java new file mode 100644 index 00000000..2e5d6133 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java @@ -0,0 +1,172 @@ +package gg.agit.konect.infrastructure.oauth; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.auth.util.SecureTokenGenerator; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleDriveOAuthService { + + private static final String GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String DRIVE_SCOPE = "https://www.googleapis.com/auth/drive"; + private static final String STATE_KEY_PREFIX = "drive:oauth:state:"; + private static final Duration STATE_TTL = Duration.ofMinutes(10); + private static final String CALLBACK_PATH = "/auth/oauth/google/drive/callback"; + + private static final DefaultRedisScript GET_DEL_SCRIPT = new DefaultRedisScript<>( + "local v = redis.call('GET', KEYS[1]); if v then redis.call('DEL', KEYS[1]); end; return v;", + String.class + ); + + private final GoogleSheetsProperties googleSheetsProperties; + private final UserOAuthAccountRepository userOAuthAccountRepository; + private final RestTemplate restTemplate; + private final StringRedisTemplate redis; + private final SecureTokenGenerator secureTokenGenerator; + + public String buildAuthorizationUrl(Integer userId) { + String state = secureTokenGenerator.generate(); + redis.opsForValue().set(STATE_KEY_PREFIX + state, userId.toString(), STATE_TTL); + + String callbackUri = buildCallbackUri(); + + return UriComponentsBuilder.fromHttpUrl(GOOGLE_AUTH_URL) + .queryParam("client_id", googleSheetsProperties.oauthClientId()) + .queryParam("redirect_uri", callbackUri) + .queryParam("response_type", "code") + .queryParam("scope", DRIVE_SCOPE) + .queryParam("access_type", "offline") + .queryParam("prompt", "consent") + .queryParam("state", state) + .build() + .toUriString(); + } + + @Transactional + public void exchangeAndSaveToken(String code, String state) { + if (!StringUtils.hasText(code) || !StringUtils.hasText(state)) { + log.warn("Drive OAuth callback received empty code or state."); + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String stateKey = STATE_KEY_PREFIX + state; + String userIdStr = redis.execute(GET_DEL_SCRIPT, List.of(stateKey)); + + if (userIdStr == null || userIdStr.isBlank()) { + log.warn("Invalid or expired Drive OAuth state. state={}", state); + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + Integer userId; + try { + userId = Integer.parseInt(userIdStr); + } catch (NumberFormatException e) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String refreshToken = requestRefreshToken(code); + + if (refreshToken == null) { + handleMissingRefreshToken(userId); + return; + } + + persistGoogleRefreshToken(userId, refreshToken); + } + + public boolean isDriveConnected(Integer userId) { + return userOAuthAccountRepository + .findByUserIdAndProvider(userId, Provider.GOOGLE) + .map(account -> StringUtils.hasText(account.getGoogleDriveRefreshToken())) + .orElse(false); + } + + private void persistGoogleRefreshToken(Integer userId, String refreshToken) { + UserOAuthAccount account = userOAuthAccountRepository + .findByUserIdAndProvider(userId, Provider.GOOGLE) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH)); + + account.updateGoogleDriveRefreshToken(refreshToken); + log.info("Google Drive refresh token saved. userId={}", userId); + } + + private void handleMissingRefreshToken(Integer userId) { + UserOAuthAccount existing = userOAuthAccountRepository + .findByUserIdAndProvider(userId, Provider.GOOGLE) + .orElse(null); + + if (existing != null && StringUtils.hasText(existing.getGoogleDriveRefreshToken())) { + log.info("Re-authorization detected, keeping existing refresh token. userId={}", userId); + return; + } + + log.error("No refresh_token received and no existing token. userId={}", userId); + throw CustomException.of(ApiResponseCode.FAILED_GOOGLE_DRIVE_AUTH); + } + + private String requestRefreshToken(String code) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", code); + params.add("client_id", googleSheetsProperties.oauthClientId()); + params.add("client_secret", googleSheetsProperties.oauthClientSecret()); + params.add("redirect_uri", buildCallbackUri()); + params.add("grant_type", "authorization_code"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + HttpEntity> request = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = + restTemplate.postForEntity(GOOGLE_TOKEN_URL, request, Map.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.error("Failed to exchange Drive OAuth code for tokens. status={}", + response.getStatusCode()); + throw CustomException.of(ApiResponseCode.FAILED_GOOGLE_DRIVE_AUTH); + } + + return (String)response.getBody().get("refresh_token"); + + } catch (RestClientException e) { + log.error("RestClient error while exchanging Drive OAuth code. cause={}", e.getMessage(), e); + throw CustomException.of(ApiResponseCode.FAILED_GOOGLE_DRIVE_AUTH); + } + } + + private String buildCallbackUri() { + String base = googleSheetsProperties.oauthCallbackBaseUrl(); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base + CALLBACK_PATH; + } +} diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index a92a092a..e006a42b 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -26,3 +26,6 @@ google: credentials-path: ${GOOGLE_SHEETS_CREDENTIALS_PATH} application-name: ${GOOGLE_SHEETS_APP_NAME:KONECT} template-spreadsheet-id: ${GOOGLE_SHEETS_TEMPLATE_ID:} + oauth-client-id: ${OAUTH_GOOGLE_CLIENT_ID} + oauth-client-secret: ${OAUTH_GOOGLE_CLIENT_SECRET} + oauth-callback-base-url: ${APP_BACKEND_BASE_URL} diff --git a/src/main/resources/db/migration/V58__add_google_drive_refresh_token_to_user_oauth_account.sql b/src/main/resources/db/migration/V58__add_google_drive_refresh_token_to_user_oauth_account.sql new file mode 100644 index 00000000..f8521510 --- /dev/null +++ b/src/main/resources/db/migration/V58__add_google_drive_refresh_token_to_user_oauth_account.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_oauth_account + ADD COLUMN google_drive_refresh_token VARCHAR(1024) NULL; From 006625af48e949b37870a7b15047731926595aad Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:44:23 +0900 Subject: [PATCH 19/55] =?UTF-8?q?refactor:=20Google=20Sheet=20API=20-=20Sh?= =?UTF-8?q?eet=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: migrate 시 서비스 계정에 새 파일 편집 권한 자동 부여 * fix: getClientEmail() 메서드명 수정 * fix: coderabbit 리뷰 반영 - 서비스 계정 권한 부여 실패 시 예외 처리 강화 * fix: 권한 부여 실패 시 고아 파일 삭제 보상 처리 추가 * fix: 트랜잭션 롤백 시 Drive 고아 파일 자동 삭제 보상 처리 추가 * fix: readAllData/writeToTemplate IO 실패 시 예외 전파하여 트랜잭션 롤백 보장 * fix: 고아 파일 이중 삭제 방지 - 롤백 훅에 일원화 --- .../club/service/SheetMigrationService.java | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index c418f55c..2006b683 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -12,12 +12,17 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.drive.Drive; import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.Permission; import com.google.api.services.sheets.v4.Sheets; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.model.Club; @@ -47,6 +52,7 @@ public class SheetMigrationService { private String defaultTemplateSpreadsheetId; private final Sheets googleSheetsService; + private final GoogleCredentials googleCredentials; private final SheetHeaderMapper sheetHeaderMapper; private final ClubRepository clubRepository; private final UserOAuthAccountRepository userOAuthAccountRepository; @@ -89,6 +95,8 @@ public String migrateToTemplate( String folderId = resolveFolderId(userDriveService, sourceSpreadsheetUrl, sourceSpreadsheetId); String newSpreadsheetId = copyTemplate(userDriveService, templateId, club.getName(), folderId); + registerDriveRollback(userDriveService, newSpreadsheetId); + grantServiceAccountAccess(userDriveService, newSpreadsheetId); SheetHeaderMapper.SheetAnalysisResult sourceAnalysis = sheetHeaderMapper.analyzeAllSheets(sourceSpreadsheetId); @@ -123,6 +131,49 @@ public String migrateToTemplate( return newSpreadsheetId; } + private void grantServiceAccountAccess(Drive userDriveService, String fileId) { + if (!(googleCredentials instanceof ServiceAccountCredentials sac)) { + throw new IllegalStateException( + "Google credentials is not a ServiceAccountCredentials. actual type=" + + googleCredentials.getClass().getName() + ); + } + String serviceAccountEmail = sac.getClientEmail(); + try { + Permission permission = new Permission() + .setType("user") + .setRole("writer") + .setEmailAddress(serviceAccountEmail); + userDriveService.permissions().create(fileId, permission) + .setSendNotificationEmail(false) + .execute(); + log.info("Service account granted access. fileId={}, email={}", fileId, serviceAccountEmail); + } catch (IOException e) { + log.error("Failed to grant service account access. fileId={}, cause={}", fileId, e.getMessage(), e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private void registerDriveRollback(Drive driveService, String fileId) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + deleteFile(driveService, fileId); + } + } + }); + } + + private void deleteFile(Drive driveService, String fileId) { + try { + driveService.files().delete(fileId).execute(); + log.info("Orphaned file deleted. fileId={}", fileId); + } catch (IOException ex) { + log.warn("Failed to delete orphaned file. fileId={}, cause={}", fileId, ex.getMessage()); + } + } + private String resolveFolderId(Drive driveService, String url, String spreadsheetId) { Matcher m = FOLDER_ID_PATTERN.matcher(url); if (m.find()) { @@ -180,7 +231,7 @@ private List> readAllData( } catch (IOException e) { log.error("Failed to read source data. cause={}", e.getMessage(), e); - return List.of(); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } } @@ -223,6 +274,7 @@ private void writeToTemplate( } catch (IOException e) { log.error("Failed to write data to template. cause={}", e.getMessage(), e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } } From f77158a3644e7f8b3d585e3d9cc6a0b9912d6a72 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:51:03 +0900 Subject: [PATCH 20/55] =?UTF-8?q?fix:=20Google=20Sheet=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1=20=EC=9C=84=EC=B9=98=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20&=20=EA=B6=8C=ED=95=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: sheet migrate 폴더 지정 버그 수정 및 import 중복 방어 강화 * refactor: SheetImportService N+1 쿼리 제거 * refactor: PreMemberKey projection 도입 및 루프 내 중복 방어 강화 * fix: checkstyle EmptyLineSeparator 수정 --- .../club/repository/ClubMemberRepository.java | 8 +++++ .../repository/ClubPreMemberRepository.java | 13 ++++++++ .../club/service/SheetImportService.java | 30 +++++++++++++++++-- .../club/service/SheetMigrationService.java | 26 +++++++++++----- 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 6a08d2a9..44981af4 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -193,4 +193,12 @@ long countByClubIdAndPosition( @Query("SELECT COUNT(cm) FROM ClubMember cm") long countAll(); + + @Query(""" + SELECT cm.user.studentNumber + FROM ClubMember cm + WHERE cm.club.id = :clubId + AND cm.user.deletedAt IS NULL + """) + Set findStudentNumbersByClubId(@Param("clubId") Integer clubId); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java index b8ace676..7e9dbc71 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java @@ -63,6 +63,19 @@ List findAllByUniversityIdAndStudentNumberAndName( boolean existsByClubIdAndStudentNumberAndName(Integer clubId, String studentNumber, String name); + @Query(""" + SELECT cpm.studentNumber as studentNumber, cpm.name as name + FROM ClubPreMember cpm + WHERE cpm.club.id = :clubId + """) + List findStudentNumberAndNameByClubId(@Param("clubId") Integer clubId); + + interface PreMemberKey { + String getStudentNumber(); + + String getName(); + } + void deleteByClubIdAndStudentNumber(Integer clubId, String studentNumber); void delete(ClubPreMember preMember); diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 108f0a31..9c57bf14 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -1,7 +1,9 @@ package gg.agit.konect.domain.club.service; import java.io.IOException; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,6 +15,7 @@ import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubPreMember; import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import lombok.RequiredArgsConstructor; @@ -27,6 +30,7 @@ public class SheetImportService { private final SheetHeaderMapper sheetHeaderMapper; private final ClubRepository clubRepository; private final ClubPreMemberRepository clubPreMemberRepository; + private final ClubMemberRepository clubMemberRepository; private final ClubPermissionValidator clubPermissionValidator; @Transactional @@ -45,6 +49,12 @@ public int importPreMembersFromSheet( SheetColumnMapping mapping = analysis.memberListMapping(); List> rows = readDataRows(spreadsheetId, mapping); + + // N+1 방지: 루프 전에 기존 부원/사전 회원 학번 Set을 한 번만 조회 + Set existingMemberStudentNumbers = + clubMemberRepository.findStudentNumbersByClubId(clubId); + Set existingPreMemberKeys = buildPreMemberKeySet(clubId); + int imported = 0; for (List row : rows) { @@ -55,9 +65,11 @@ public int importPreMembersFromSheet( continue; } - if (clubPreMemberRepository.existsByClubIdAndStudentNumberAndName( - clubId, studentNumber, name - )) { + if (existingPreMemberKeys.contains(preMemberKey(studentNumber, name))) { + continue; + } + + if (existingMemberStudentNumbers.contains(studentNumber)) { continue; } @@ -72,6 +84,7 @@ public int importPreMembersFromSheet( .build(); clubPreMemberRepository.save(preMember); + existingPreMemberKeys.add(preMemberKey(studentNumber, name)); imported++; } @@ -120,4 +133,15 @@ private ClubPosition resolvePosition(String positionStr) { } return ClubPosition.MEMBER; } + + private Set buildPreMemberKeySet(Integer clubId) { + Set keys = new HashSet<>(); + clubPreMemberRepository.findStudentNumberAndNameByClubId(clubId) + .forEach(k -> keys.add(preMemberKey(k.getStudentNumber(), k.getName()))); + return keys; + } + + private String preMemberKey(String studentNumber, String name) { + return studentNumber + "\u0000" + name; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index 2006b683..bd6fbcc9 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -198,16 +198,28 @@ private String copyTemplate(Drive driveService, String templateId, String clubNa String title = NEW_SHEET_TITLE_PREFIX + clubName; File copyMetadata = new File().setName(title); - if (targetFolderId != null) { - copyMetadata.setParents(Collections.singletonList(targetFolderId)); - } - + // Drive API v3에서 files().copy() 바디의 parents 필드는 무시됨. + // 복사 후 files().update()로 addParents/removeParents를 명시적으로 호출해야 폴더 이동이 적용됨. File copied = driveService.files().copy(templateId, copyMetadata) - .setFields("id") + .setFields("id, parents") .execute(); - log.info("Template copied by user. newId={}, folderId={}", copied.getId(), targetFolderId); - return copied.getId(); + String newFileId = copied.getId(); + + if (targetFolderId != null) { + List currentParents = copied.getParents(); + String removeParents = (currentParents != null && !currentParents.isEmpty()) + ? String.join(",", currentParents) + : ""; + driveService.files().update(newFileId, new File()) + .setAddParents(targetFolderId) + .setRemoveParents(removeParents) + .setFields("id, parents") + .execute(); + } + + log.info("Template copied by user. newId={}, folderId={}", newFileId, targetFolderId); + return newFileId; } catch (IOException e) { log.error("Failed to copy template. cause={}", e.getMessage(), e); From a2d71bf18eae3b57874026135e89681dec73b79c Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:29:00 +0900 Subject: [PATCH 21/55] =?UTF-8?q?fix:=20Google=20Sheet=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Google Sheet Import/Migration 버그 수정 및 데이터 품질 개선 (CAM-239) * refactor: coderabbit 리뷰 반영 (CAM-239) * fix: coderabbit 2라운드 리뷰 반영 (CAM-239) * refactor: coderabbit 3라운드 리뷰 반영 (CAM-239) * fix: coderabbit 4라운드 리뷰 반영 (CAM-239) --- .../ClubSheetMigrationController.java | 4 +- .../domain/club/dto/SheetImportResponse.java | 21 ++- .../club/repository/ClubMemberRepository.java | 2 + .../repository/ClubPreMemberRepository.java | 14 ++ .../club/service/SheetImportService.java | 148 ++++++++++++++++-- .../club/service/SheetMigrationService.java | 98 +++++++++++- .../user/repository/UserRepository.java | 13 ++ .../global/util/PhoneNumberNormalizer.java | 40 +++++ 8 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java index c049e40e..b2ff2387 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -42,9 +42,9 @@ public ResponseEntity importPreMembers( @Valid @RequestBody SheetImportRequest request, @UserId Integer requesterId ) { - int count = sheetImportService.importPreMembersFromSheet( + SheetImportResponse response = sheetImportService.importPreMembersFromSheet( clubId, requesterId, request.spreadsheetUrl() ); - return ResponseEntity.ok(SheetImportResponse.of(count)); + return ResponseEntity.ok(response); } } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java index 91225711..4a11faf1 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java @@ -1,9 +1,26 @@ package gg.agit.konect.domain.club.dto; +import java.util.List; + public record SheetImportResponse( - int importedCount + int importedCount, + int autoRegisteredCount, + List warnings ) { + public static SheetImportResponse of(int importedCount) { - return new SheetImportResponse(importedCount); + return new SheetImportResponse(importedCount, 0, List.of()); + } + + public static SheetImportResponse of( + int importedCount, + int autoRegisteredCount, + List warnings + ) { + return new SheetImportResponse( + importedCount, + autoRegisteredCount, + warnings != null ? warnings : List.of() + ); } } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 44981af4..8e907d6c 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -189,6 +189,8 @@ long countByClubIdAndPosition( ClubMember save(ClubMember clubMember); + List saveAll(Iterable clubMembers); + void deleteByUserId(Integer userId); @Query("SELECT COUNT(cm) FROM ClubMember cm") diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java index 7e9dbc71..eec13a77 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -78,7 +79,20 @@ interface PreMemberKey { void deleteByClubIdAndStudentNumber(Integer clubId, String studentNumber); + @Query(""" + DELETE FROM ClubPreMember cpm + WHERE cpm.club.id = :clubId + AND cpm.studentNumber IN :studentNumbers + """) + @org.springframework.data.jpa.repository.Modifying(clearAutomatically = true) + void deleteByClubIdAndStudentNumberIn( + @Param("clubId") Integer clubId, + @Param("studentNumbers") Set studentNumbers + ); + void delete(ClubPreMember preMember); ClubPreMember save(ClubPreMember preMember); + + List saveAll(Iterable preMembers); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 9c57bf14..5d5664ec 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -1,9 +1,13 @@ package gg.agit.konect.domain.club.service; import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,13 +15,21 @@ import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubPreMember; import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.util.PhoneNumberNormalizer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,16 +43,19 @@ public class SheetImportService { private final ClubRepository clubRepository; private final ClubPreMemberRepository clubPreMemberRepository; private final ClubMemberRepository clubMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; private final ClubPermissionValidator clubPermissionValidator; @Transactional - public int importPreMembersFromSheet( + public SheetImportResponse importPreMembersFromSheet( Integer clubId, Integer requesterId, String spreadsheetUrl ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); Club club = clubRepository.getById(clubId); + Integer universityId = club.getUniversity().getId(); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); @@ -50,12 +65,34 @@ public int importPreMembersFromSheet( List> rows = readDataRows(spreadsheetId, mapping); - // N+1 방지: 루프 전에 기존 부원/사전 회원 학번 Set을 한 번만 조회 + // N+1 방지: 루프 전 기존 부원 학번 Set / 사전 회원 key Set / 부원 userId Set 일괄 조회 Set existingMemberStudentNumbers = - clubMemberRepository.findStudentNumbersByClubId(clubId); + new HashSet<>(clubMemberRepository.findStudentNumbersByClubId(clubId)); Set existingPreMemberKeys = buildPreMemberKeySet(clubId); + Set existingMemberUserIds = + new HashSet<>(clubMemberRepository.findUserIdsByClubId(clubId)); - int imported = 0; + // 시트에 등장하는 모든 학번 수집 → users 일괄 조회 + Set allStudentNumbers = rows.stream() + .map(row -> getCell(row, mapping, SheetColumnMapping.STUDENT_ID)) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + + Map> usersByStudentNumber = new HashMap<>(); + if (!allStudentNumbers.isEmpty()) { + userRepository.findAllByUniversityIdAndStudentNumberIn(universityId, allStudentNumbers) + .forEach(u -> usersByStudentNumber + .computeIfAbsent(u.getStudentNumber(), k -> new ArrayList<>()) + .add(u)); + } + + // 루프에서 수집할 배치 작업 대상 + List clubMembersToSave = new ArrayList<>(); + Set studentNumbersToCleanFromPre = new HashSet<>(); + List preMembersToSave = new ArrayList<>(); + + List warnings = new ArrayList<>(); + int presidentCount = 0; for (List row : rows) { String name = getCell(row, mapping, SheetColumnMapping.NAME); @@ -65,34 +102,110 @@ public int importPreMembersFromSheet( continue; } - if (existingPreMemberKeys.contains(preMemberKey(studentNumber, name))) { - continue; + // 전화번호 형식 유효성 경고 + String phone = getCell(row, mapping, SheetColumnMapping.PHONE); + if (!phone.isBlank() && !PhoneNumberNormalizer.looksLikePhoneNumber(phone)) { + warnings.add(String.format( + "전화번호 형식이 올바르지 않습니다 - 학번: %s, 이름: %s, 입력값: '%s'", + studentNumber, name, phone + )); } + String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); + ClubPosition position = resolvePosition(positionStr); + + // 회장 중복 감지 + if (position == ClubPosition.PRESIDENT) { + presidentCount++; + if (presidentCount > 1) { + warnings.add(String.format( + "회장이 2명 이상 등록되어 있습니다 - 중복 회장: 학번 %s, 이름 %s", + studentNumber, name + )); + } + } + + // 이미 club_member에 있는 학번은 스킵 if (existingMemberStudentNumbers.contains(studentNumber)) { continue; } - String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); - ClubPosition position = resolvePosition(positionStr); + // users 테이블에서 동일 대학 + 학번으로 매칭, 이름까지 일치하는 유저 탐색 + // trim() / equalsIgnoreCase로 공백·대소문자 차이 허용 + // 주의: existingPreMemberKeys 체크보다 먼저 수행하여 + // 이미 pre_member로 등록된 행도 User 생성 후 재-import 시 club_member로 승격 가능하게 함 + List candidates = usersByStudentNumber.getOrDefault(studentNumber, List.of()); + List matched = candidates.stream() + .filter(u -> name != null && u.getName() != null + && name.trim().equalsIgnoreCase(u.getName().trim())) + .toList(); + + if (matched.size() == 1) { + User matchedUser = matched.get(0); + // userId Set으로 중복 체크 (N+1 없음) + if (!existingMemberUserIds.contains(matchedUser.getId())) { + // 기존 pre_member 행도 함께 정리 (중복 방지) + studentNumbersToCleanFromPre.add(matchedUser.getStudentNumber()); + clubMembersToSave.add(ClubMember.builder() + .club(club) + .user(matchedUser) + .clubPosition(position) + .build()); + existingMemberStudentNumbers.add(studentNumber); + existingMemberUserIds.add(matchedUser.getId()); + existingPreMemberKeys.remove(preMemberKey(studentNumber, name)); + } + continue; + } + + if (matched.size() > 1) { + warnings.add(String.format( + "동명이인이 여러 명 존재하여 자동 매칭할 수 없습니다 - 학번: %s, 이름: %s", + studentNumber, name + )); + } + + // users 미매칭 또는 동명이인 → 이미 pre_member에 있으면 스킵, 없으면 등록 + if (existingPreMemberKeys.contains(preMemberKey(studentNumber, name))) { + continue; + } - ClubPreMember preMember = ClubPreMember.builder() + preMembersToSave.add(ClubPreMember.builder() .club(club) .studentNumber(studentNumber) .name(name) .clubPosition(position) - .build(); - - clubPreMemberRepository.save(preMember); + .build()); existingPreMemberKeys.add(preMemberKey(studentNumber, name)); - imported++; } + // 배치 처리: pre_member 정리 → club_member 일괄 저장 → 채팅방 등록 + if (!studentNumbersToCleanFromPre.isEmpty()) { + clubPreMemberRepository.deleteByClubIdAndStudentNumberIn( + clubId, studentNumbersToCleanFromPre + ); + } + List savedMembers = clubMembersToSave.isEmpty() + ? List.of() + : clubMemberRepository.saveAll(clubMembersToSave); + + for (ClubMember saved : savedMembers) { + chatRoomMembershipService.addClubMember(saved); + } + + if (!preMembersToSave.isEmpty()) { + clubPreMemberRepository.saveAll(preMembersToSave); + } + + int autoRegistered = savedMembers.size(); + int imported = preMembersToSave.size(); + log.info( - "Sheet import done. clubId={}, spreadsheetId={}, imported={}", - clubId, spreadsheetId, imported + "Sheet import done. clubId={}, spreadsheetId={}, imported={}, autoRegistered={}, " + + "warnings={}", + clubId, spreadsheetId, imported, autoRegistered, warnings.size() ); - return imported; + return SheetImportResponse.of(imported, autoRegistered, warnings); } private List> readDataRows(String spreadsheetId, SheetColumnMapping mapping) { @@ -101,6 +214,7 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping String range = "A" + dataStartRow + ":Z"; ValueRange response = googleSheetsService.spreadsheets().values() .get(spreadsheetId, range) + .setValueRenderOption("FORMATTED_VALUE") .execute(); List> values = response.getValues(); @@ -108,7 +222,7 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping } catch (IOException e) { log.error("Failed to read sheet data. spreadsheetId={}", spreadsheetId, e); - return List.of(); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index bd6fbcc9..83b1ed76 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -21,9 +21,9 @@ import com.google.api.services.drive.model.File; import com.google.api.services.drive.model.Permission; import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; -import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.SheetColumnMapping; @@ -31,8 +31,9 @@ import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; -import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.util.PhoneNumberNormalizer; import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -94,6 +95,11 @@ public String migrateToTemplate( String sourceSpreadsheetId = SpreadsheetUrlParser.extractId(sourceSpreadsheetUrl); String folderId = resolveFolderId(userDriveService, sourceSpreadsheetUrl, sourceSpreadsheetId); + // 소스 파일에 서비스 계정 reader 권한을 먼저 부여해야 readAllData()가 성공함 + grantServiceAccountReadAccess(userDriveService, sourceSpreadsheetId); + // 트랜잭션 실패 / 완료 후 소스 파일 서비스 계정 권한 제거 (보상 처리) + registerSourceFilePermissionCleanup(userDriveService, sourceSpreadsheetId); + String newSpreadsheetId = copyTemplate(userDriveService, templateId, club.getName(), folderId); registerDriveRollback(userDriveService, newSpreadsheetId); grantServiceAccountAccess(userDriveService, newSpreadsheetId); @@ -131,7 +137,75 @@ public String migrateToTemplate( return newSpreadsheetId; } + /** + * 소스 파일에 서비스 계정 reader 권한을 부여합니다. + * migrate 시 서비스 계정 Sheets API로 소스 데이터를 읽어야 하므로 필요합니다. + */ + private void grantServiceAccountReadAccess(Drive userDriveService, String fileId) { + grantServiceAccountPermission(userDriveService, fileId, "reader"); + } + + /** + * 트랜잭션 완료(성공/실패 모두) 후 소스 파일에서 서비스 계정 권한을 제거합니다. + * 서비스 계정의 파일 접근을 최소화하기 위한 보상 처리입니다. + */ + private void registerSourceFilePermissionCleanup(Drive driveService, String fileId) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + removeServiceAccountPermission(driveService, fileId); + } + }); + } + + private void removeServiceAccountPermission(Drive driveService, String fileId) { + if (!(googleCredentials instanceof ServiceAccountCredentials sac)) { + return; + } + String serviceAccountEmail = sac.getClientEmail(); + try { + // permissionId 조회 후 삭제 (getPermissions()는 빈 경우 null 반환 가능) + List permissions = + driveService.permissions().list(fileId) + .setFields("permissions(id,emailAddress)") + .execute() + .getPermissions(); + if (permissions == null) { + return; + } + permissions.stream() + .filter(p -> serviceAccountEmail.equals(p.getEmailAddress())) + .findFirst() + .ifPresent(p -> { + try { + driveService.permissions().delete(fileId, p.getId()).execute(); + log.info( + "Service account permission removed from source file. fileId={}", + fileId + ); + } catch (IOException ex) { + log.warn( + "Failed to remove service account permission. fileId={}, cause={}", + fileId, ex.getMessage() + ); + } + }); + } catch (IOException e) { + log.warn( + "Failed to list permissions for source file cleanup. fileId={}, cause={}", + fileId, e.getMessage() + ); + } + } + private void grantServiceAccountAccess(Drive userDriveService, String fileId) { + grantServiceAccountPermission(userDriveService, fileId, "writer"); + } + + /** + * 서비스 계정에 지정된 role로 Drive 접근 권한을 부여하는 공통 메서드입니다. + */ + private void grantServiceAccountPermission(Drive userDriveService, String fileId, String role) { if (!(googleCredentials instanceof ServiceAccountCredentials sac)) { throw new IllegalStateException( "Google credentials is not a ServiceAccountCredentials. actual type=" @@ -142,14 +216,20 @@ private void grantServiceAccountAccess(Drive userDriveService, String fileId) { try { Permission permission = new Permission() .setType("user") - .setRole("writer") + .setRole(role) .setEmailAddress(serviceAccountEmail); userDriveService.permissions().create(fileId, permission) .setSendNotificationEmail(false) .execute(); - log.info("Service account granted access. fileId={}, email={}", fileId, serviceAccountEmail); + log.info( + "Service account {} access granted. fileId={}, email={}", + role, fileId, serviceAccountEmail + ); } catch (IOException e) { - log.error("Failed to grant service account access. fileId={}, cause={}", fileId, e.getMessage(), e); + log.error( + "Failed to grant service account {} access. fileId={}, cause={}", + role, fileId, e.getMessage(), e + ); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } } @@ -236,6 +316,7 @@ private List> readAllData( String range = "A" + dataStartRow + ":Z"; ValueRange response = googleSheetsService.spreadsheets().values() .get(spreadsheetId, range) + .setValueRenderOption("FORMATTED_VALUE") .execute(); List> values = response.getValues(); @@ -326,7 +407,12 @@ private List buildTargetRow( int targetCol = targetMapping.getColumnIndex(field); if (targetCol >= 0 && sourceCol < sourceRow.size()) { - row.set(targetCol, sourceRow.get(sourceCol)); + Object cellValue = sourceRow.get(sourceCol); + // 전화번호 컬럼은 저장 전 형식 정규화 (010-xxxx-xxxx -> 01xxxxxxxxxx) + if (SheetColumnMapping.PHONE.equals(field) && cellValue != null) { + cellValue = PhoneNumberNormalizer.normalize(cellValue.toString()); + } + row.set(targetCol, cellValue); } } diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 8669e09b..86dc810f 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -42,6 +43,18 @@ List findAllByUniversityIdAndStudentNumber( @Param("studentNumber") String studentNumber ); + @Query(""" + SELECT u + FROM User u + WHERE u.university.id = :universityId + AND u.studentNumber IN :studentNumbers + AND u.deletedAt IS NULL + """) + List findAllByUniversityIdAndStudentNumberIn( + @Param("universityId") Integer universityId, + @Param("studentNumbers") Set studentNumbers + ); + User save(User user); @Query(""" diff --git a/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java b/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java new file mode 100644 index 00000000..e8e70924 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java @@ -0,0 +1,40 @@ +package gg.agit.konect.global.util; + +public final class PhoneNumberNormalizer { + + private static final int PHONE_NUMBER_MIN_DIGITS = 9; + private static final int PHONE_NUMBER_MAX_DIGITS = 11; + + private PhoneNumberNormalizer() { + } + + /** + * 다양한 형식의 전화번호 문자열을 숫자만 남긴 형태로 정규화합니다. + * 예) 010-1234-5678 -> 01012345678 + * (010) 1234-5678 -> 01012345678 + * 010 1234 5678 -> 01012345678 + */ + public static String normalize(String phone) { + if (phone == null) { + return null; + } + String trimmed = phone.trim(); + if (trimmed.isEmpty()) { + return ""; + } + return trimmed.replaceAll("[^0-9]", ""); + } + + /** + * 전화번호처럼 보이는지 간단히 검증합니다. + * 숫자만 남겼을 때 9~11자리면 유효한 것으로 판단합니다. + */ + public static boolean looksLikePhoneNumber(String phone) { + if (phone == null || phone.isBlank()) { + return false; + } + String digits = phone.replaceAll("[^0-9]", ""); + return digits.length() >= PHONE_NUMBER_MIN_DIGITS + && digits.length() <= PHONE_NUMBER_MAX_DIGITS; + } +} From 5e464eb42b929a91759f9cc2cc01431e7699cd47 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:07:51 +0900 Subject: [PATCH 22/55] =?UTF-8?q?refactor:=20Google=20Sheet=20API=20?= =?UTF-8?q?=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add phone number format() method for 010-xxxx-xxxx formatting * fix migration: apply phone format(), recover parent folder on copy() * fix sync: replace tick-prefix phone with PhoneNumberNormalizer.format() * 전화번호 복구 조건 좁히기, 불필요 분기 제거, 상수 중복 해소 및 Javadoc 수정 * 마이그레이션 parents 재조회 실패 시 예외 처리로 롤백 보장 * copyTemplate 예외 경로에서 고아 파일 정리 보장 --- .../club/service/SheetMigrationService.java | 41 ++++++++-- .../club/service/SheetSyncExecutor.java | 11 ++- .../global/util/PhoneNumberNormalizer.java | 76 +++++++++++++++++++ 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index 83b1ed76..c9439309 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -274,6 +274,8 @@ private String resolveFolderId(Drive driveService, String url, String spreadshee } private String copyTemplate(Drive driveService, String templateId, String clubName, String targetFolderId) { + // newFileId를 try 바깥에서 선언하여 예외 경로에서도 고아 파일 정리가 가능하도록 함 + String newFileId = null; try { String title = NEW_SHEET_TITLE_PREFIX + clubName; File copyMetadata = new File().setName(title); @@ -284,13 +286,37 @@ private String copyTemplate(Drive driveService, String templateId, String clubNa .setFields("id, parents") .execute(); - String newFileId = copied.getId(); + newFileId = copied.getId(); if (targetFolderId != null) { List currentParents = copied.getParents(); - String removeParents = (currentParents != null && !currentParents.isEmpty()) - ? String.join(",", currentParents) - : ""; + // copy() 응답에서 parents가 null로 오는 경우 별도 GET 으로 재조회 + if (currentParents == null || currentParents.isEmpty()) { + try { + File fileInfo = driveService.files().get(newFileId) + .setFields("parents") + .execute(); + currentParents = fileInfo.getParents(); + log.debug( + "Re-fetched parents for copied file. fileId={}, parents={}", + newFileId, currentParents + ); + } catch (IOException ex) { + log.error( + "Failed to re-fetch parents for copied file. fileId={}, cause={}", + newFileId, ex.getMessage() + ); + deleteFile(driveService, newFileId); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + // parents를 끝내 확보하지 못한 경우, 폴더 이동이 보장되지 않으므로 예외로 처리해 롤백 + if (currentParents == null || currentParents.isEmpty()) { + log.error("Cannot determine parents for copied file. fileId={}", newFileId); + deleteFile(driveService, newFileId); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + String removeParents = String.join(",", currentParents); driveService.files().update(newFileId, new File()) .setAddParents(targetFolderId) .setRemoveParents(removeParents) @@ -302,6 +328,9 @@ private String copyTemplate(Drive driveService, String templateId, String clubNa return newFileId; } catch (IOException e) { + if (newFileId != null) { + deleteFile(driveService, newFileId); + } log.error("Failed to copy template. cause={}", e.getMessage(), e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } @@ -408,9 +437,9 @@ private List buildTargetRow( if (targetCol >= 0 && sourceCol < sourceRow.size()) { Object cellValue = sourceRow.get(sourceCol); - // 전화번호 컬럼은 저장 전 형식 정규화 (010-xxxx-xxxx -> 01xxxxxxxxxx) + // 전화번호 컬럼은 010-xxxx-xxxx 형식으로 포맷팅 (0 잘림 복구 포함) if (SheetColumnMapping.PHONE.equals(field) && cellValue != null) { - cellValue = PhoneNumberNormalizer.normalize(cellValue.toString()); + cellValue = PhoneNumberNormalizer.format(cellValue.toString()); } row.set(targetCol, cellValue); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index a6eea1fc..5b58d63d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -34,6 +34,7 @@ import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.util.PhoneNumberNormalizer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -179,8 +180,7 @@ private Map> buildColumnData( putValue(columns, mapping, SheetColumnMapping.EMAIL, member.getUser().getEmail()); putValue(columns, mapping, SheetColumnMapping.PHONE, - member.getUser().getPhoneNumber() != null - ? "'" + member.getUser().getPhoneNumber() : ""); + PhoneNumberNormalizer.format(member.getUser().getPhoneNumber())); putValue(columns, mapping, SheetColumnMapping.POSITION, member.getClubPosition().getDescription()); putValue(columns, mapping, SheetColumnMapping.JOINED_AT, @@ -198,7 +198,7 @@ private void putValue( ) { int colIndex = mapping.getColumnIndex(field); if (colIndex >= 0) { - columns.computeIfAbsent(colIndex, k -> new ArrayList<>()).add(value); + columns.computeIfAbsent(colIndex, k -> new ArrayList<>()).add(value != null ? value : ""); } } @@ -215,13 +215,12 @@ private void clearAndWriteAll( rows.add(HEADER_ROW); for (ClubMember member : members) { - String phone = member.getUser().getPhoneNumber() != null - ? "'" + member.getUser().getPhoneNumber() : ""; + String phone = PhoneNumberNormalizer.format(member.getUser().getPhoneNumber()); rows.add(List.of( member.getUser().getName(), member.getUser().getStudentNumber(), member.getUser().getEmail(), - phone, + phone != null ? phone : "", member.getClubPosition().getDescription(), member.getCreatedAt().format(DATE_FORMATTER) )); diff --git a/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java b/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java index e8e70924..5e9cd5cf 100644 --- a/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java +++ b/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java @@ -5,6 +5,16 @@ public final class PhoneNumberNormalizer { private static final int PHONE_NUMBER_MIN_DIGITS = 9; private static final int PHONE_NUMBER_MAX_DIGITS = 11; + private static final int DIGITS_9 = PHONE_NUMBER_MIN_DIGITS; + private static final int DIGITS_10 = PHONE_NUMBER_MIN_DIGITS + 1; + private static final int DIGITS_11 = PHONE_NUMBER_MAX_DIGITS; + + private static final int IDX_2 = 2; + private static final int IDX_3 = 3; + private static final int IDX_5 = 5; + private static final int IDX_6 = 6; + private static final int IDX_7 = 7; + private PhoneNumberNormalizer() { } @@ -25,6 +35,72 @@ public static String normalize(String phone) { return trimmed.replaceAll("[^0-9]", ""); } + /** + * 전화번호에서 숫자만 추출한 뒤 길이와 패턴에 따라 하이픈(-)을 삽입하여 포맷팅합니다. + * 구글 시트에서 앞자리 0이 잘린 경우(10자리, 10으로 시작)도 복구합니다. + * + *

포맷 규칙은 다음과 같습니다. + *

    + *
  • 구글 시트에서 앞자리 0이 잘린 10자리 번호(10으로 시작): 앞에 0을 붙여 11자리로 복구
  • + *
  • 11자리: XXX-XXXX-XXXX
  • + *
  • 서울 지역번호 02, 10자리: 02-XXXX-XXXX
  • + *
  • 서울 지역번호 02, 9자리: 02-XXX-XXXX
  • + *
  • 그 외 10자리: XXX-XXX-XXXX
  • + *
  • 위 조건에 모두 해당하지 않는 경우: 하이픈 없이 숫자만 그대로 반환
  • + *
+ * + *

예) + * 1012345678 -> 010-1234-5678 (구글 시트에서 0이 잘린 경우 복구) + * 01012345678 -> 010-1234-5678 + * 0212345678 -> 02-1234-5678 + * 021234567 -> 02-123-4567 + * 0311234567 -> 031-123-4567 + */ + public static String format(String phone) { + if (phone == null) { + return null; + } + String digits = phone.trim().replaceAll("[^0-9]", ""); + if (digits.isEmpty()) { + return ""; + } + + // 구글 시트에서 앞자리 0이 잘린 경우 복구: 10자리이고 10으로 시작 (010 -> 10) + if (digits.length() == DIGITS_10 && digits.startsWith("10")) { + digits = "0" + digits; + } + + // 11자리: XXX-XXXX-XXXX + if (digits.length() == DIGITS_11) { + return digits.substring(0, IDX_3) + + "-" + digits.substring(IDX_3, IDX_7) + + "-" + digits.substring(IDX_7); + } + + // 서울 지역번호 02: 02-XXXX-XXXX(10자리) or 02-XXX-XXXX(9자리) + if (digits.startsWith("02")) { + if (digits.length() == DIGITS_10) { + return digits.substring(0, IDX_2) + + "-" + digits.substring(IDX_2, IDX_6) + + "-" + digits.substring(IDX_6); + } + if (digits.length() == DIGITS_9) { + return digits.substring(0, IDX_2) + + "-" + digits.substring(IDX_2, IDX_5) + + "-" + digits.substring(IDX_5); + } + } + + // 기타 10자리: XXX-XXX-XXXX + if (digits.length() == DIGITS_10) { + return digits.substring(0, IDX_3) + + "-" + digits.substring(IDX_3, IDX_6) + + "-" + digits.substring(IDX_6); + } + + return digits; + } + /** * 전화번호처럼 보이는지 간단히 검증합니다. * 숫자만 남겼을 때 9~11자리면 유효한 것으로 판단합니다. From 23852c03e57e69e90f95b0613b780c4a4c04fd7d Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:09:01 +0900 Subject: [PATCH 23/55] =?UTF-8?q?feat:=20=EC=9D=B8=EC=95=B1=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 인앱 알림 기능 추가 * style: Checkstyle 120자 위반 수정 * test: NotificationInboxApiTest 통합 테스트 추가 * fix: save() REQUIRES_NEW 트랜잭션 격리 및 정렬 타이브레이커 추가 * feat: 동아리 지원 알림 수신자를 운영진 전체로 확장 * feat: SSE 기반 실시간 인앱 알림 구독 API 추가 * fix: SSE 재구독 시 기존 emitter 명시적 종료 및 콜백 오제거 방지 * fix: SSE 전송 실패 시 emitters.remove 레이스 컨디션 방지 --- .../event/ClubApplicationSubmittedEvent.java | 10 +- .../club/repository/ClubMemberRepository.java | 13 + .../club/service/ClubApplicationService.java | 17 +- .../controller/NotificationInboxApi.java | 48 ++++ .../NotificationInboxController.java | 42 ++++ .../controller/NotificationInboxSseApi.java | 19 ++ .../NotificationInboxSseController.java | 21 ++ .../dto/NotificationInboxResponse.java | 28 +++ .../NotificationInboxUnreadCountResponse.java | 7 + .../dto/NotificationInboxesResponse.java | 25 ++ .../enums/NotificationInboxType.java | 7 + .../ClubApplicationNotificationListener.java | 14 +- .../notification/model/NotificationInbox.java | 83 +++++++ .../NotificationInboxRepository.java | 35 +++ .../service/NotificationInboxService.java | 66 +++++ .../service/NotificationInboxSseService.java | 56 +++++ .../service/NotificationService.java | 16 +- .../konect/global/code/ApiResponseCode.java | 1 + .../V59__add_notification_inbox_table.sql | 16 ++ .../V60__fix_notification_inbox_table.sql | 9 + .../V61__update_notification_inbox_index.sql | 5 + .../NotificationInboxApiTest.java | 225 ++++++++++++++++++ 22 files changed, 748 insertions(+), 15 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxApi.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxController.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseApi.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseController.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/model/NotificationInbox.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java create mode 100644 src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java create mode 100644 src/main/resources/db/migration/V59__add_notification_inbox_table.sql create mode 100644 src/main/resources/db/migration/V60__fix_notification_inbox_table.sql create mode 100644 src/main/resources/db/migration/V61__update_notification_inbox_index.sql create mode 100644 src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java index 3313a815..3eb05113 100644 --- a/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java @@ -1,19 +1,23 @@ package gg.agit.konect.domain.club.event; +import java.util.List; + public record ClubApplicationSubmittedEvent( - Integer receiverId, + List receiverIds, Integer applicationId, Integer clubId, String clubName, String applicantName ) { public static ClubApplicationSubmittedEvent of( - Integer receiverId, + List receiverIds, Integer applicationId, Integer clubId, String clubName, String applicantName ) { - return new ClubApplicationSubmittedEvent(receiverId, applicationId, clubId, clubName, applicantName); + return new ClubApplicationSubmittedEvent( + List.copyOf(receiverIds), applicationId, clubId, clubName, applicantName + ); } } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 8e907d6c..6a445434 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -66,6 +66,19 @@ List findAllByClubIdAndPosition( """) Optional findPresidentByClubId(@Param("clubId") Integer clubId); + @Query(""" + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + WHERE cm.club.id = :clubId + AND cm.clubPosition IN :positions + AND cm.user.deletedAt IS NULL + """) + List findAllByClubIdAndPositionIn( + @Param("clubId") Integer clubId, + @Param("positions") Set positions + ); + @Query(""" SELECT cm FROM ClubMember cm diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 076d7c69..d282d17a 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -1,6 +1,8 @@ package gg.agit.konect.domain.club.service; import static gg.agit.konect.domain.club.enums.ClubPosition.MEMBER; + +import gg.agit.konect.domain.club.enums.ClubPosition; import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; @@ -299,14 +301,21 @@ public ClubFeeInfoResponse applyClub(Integer clubId, Integer userId, ClubApplyRe clubApplyAnswerRepository.saveAll(applyAnswers); } - clubMemberRepository.findPresidentByClubId(clubId) - .ifPresent(president -> applicationEventPublisher.publishEvent(ClubApplicationSubmittedEvent.of( - president.getUser().getId(), + List managerIds = clubMemberRepository + .findAllByClubIdAndPositionIn(clubId, ClubPosition.MANAGERS) + .stream() + .map(manager -> manager.getUser().getId()) + .toList(); + + if (!managerIds.isEmpty()) { + applicationEventPublisher.publishEvent(ClubApplicationSubmittedEvent.of( + managerIds, apply.getId(), clubId, club.getName(), user.getName() - ))); + )); + } Integer bankId = resolveBankId(club.getFeeBank()); return ClubFeeInfoResponse.of(club, bankId, club.getFeeBank()); diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxApi.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxApi.java new file mode 100644 index 00000000..3d782842 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxApi.java @@ -0,0 +1,48 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; + +@Validated +@Tag(name = "(Normal) Notification: 알림", description = "알림 API") +@RequestMapping("/notifications/inbox") +public interface NotificationInboxApi { + + @Operation(summary = "인앱 알림 목록을 조회한다.") + @GetMapping + ResponseEntity getMyInboxes( + @UserId Integer userId, + @RequestParam(defaultValue = "1") @Min(1) int page + ); + + @Operation(summary = "인앱 알림 미읽음 개수를 조회한다.") + @GetMapping("/unread-count") + ResponseEntity getUnreadCount( + @UserId Integer userId + ); + + @Operation(summary = "인앱 알림을 읽음 처리한다.") + @PatchMapping("/{notificationId}/read") + ResponseEntity markAsRead( + @UserId Integer userId, + @PathVariable Integer notificationId + ); + + @Operation(summary = "인앱 알림 전체를 읽음 처리한다.") + @PatchMapping("/read-all") + ResponseEntity markAllAsRead( + @UserId Integer userId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxController.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxController.java new file mode 100644 index 00000000..83b0e5f0 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxController.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse; +import gg.agit.konect.domain.notification.service.NotificationInboxService; +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications/inbox") +public class NotificationInboxController implements NotificationInboxApi { + + private final NotificationInboxService notificationInboxService; + + @Override + public ResponseEntity getMyInboxes(Integer userId, int page) { + return ResponseEntity.ok(notificationInboxService.getMyInboxes(userId, page)); + } + + @Override + public ResponseEntity getUnreadCount(Integer userId) { + return ResponseEntity.ok(notificationInboxService.getUnreadCount(userId)); + } + + @Override + public ResponseEntity markAsRead(Integer userId, Integer notificationId) { + notificationInboxService.markAsRead(userId, notificationId); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity markAllAsRead(Integer userId) { + notificationInboxService.markAllAsRead(userId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseApi.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseApi.java new file mode 100644 index 00000000..f783f374 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseApi.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Notification: 알림", description = "알림 API") +@RequestMapping("/notifications/inbox") +public interface NotificationInboxSseApi { + + @Operation(summary = "인앱 알림 SSE 구독을 시작한다.") + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + SseEmitter subscribe(@UserId Integer userId); +} diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseController.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseController.java new file mode 100644 index 00000000..28ede114 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseController.java @@ -0,0 +1,21 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications/inbox") +public class NotificationInboxSseController implements NotificationInboxSseApi { + + private final NotificationInboxSseService notificationInboxSseService; + + @Override + public SseEmitter subscribe(Integer userId) { + return notificationInboxSseService.subscribe(userId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java new file mode 100644 index 00000000..3e29c709 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java @@ -0,0 +1,28 @@ +package gg.agit.konect.domain.notification.dto; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; + +public record NotificationInboxResponse( + Integer id, + NotificationInboxType type, + String title, + String body, + String path, + Boolean isRead, + LocalDateTime createdAt +) { + public static NotificationInboxResponse from(NotificationInbox inbox) { + return new NotificationInboxResponse( + inbox.getId(), + inbox.getType(), + inbox.getTitle(), + inbox.getBody(), + inbox.getPath(), + inbox.getIsRead(), + inbox.getCreatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java new file mode 100644 index 00000000..70d6fb45 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java @@ -0,0 +1,7 @@ +package gg.agit.konect.domain.notification.dto; + +public record NotificationInboxUnreadCountResponse(long unreadCount) { + public static NotificationInboxUnreadCountResponse of(long count) { + return new NotificationInboxUnreadCountResponse(count); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java new file mode 100644 index 00000000..8ea12670 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java @@ -0,0 +1,25 @@ +package gg.agit.konect.domain.notification.dto; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import gg.agit.konect.domain.notification.model.NotificationInbox; + +public record NotificationInboxesResponse( + List notifications, + int currentPage, + int totalPages, + long totalElements, + boolean hasNext +) { + public static NotificationInboxesResponse from(Page page) { + return new NotificationInboxesResponse( + page.getContent().stream().map(NotificationInboxResponse::from).toList(), + page.getNumber() + 1, + page.getTotalPages(), + page.getTotalElements(), + page.hasNext() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java new file mode 100644 index 00000000..ee708a39 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java @@ -0,0 +1,7 @@ +package gg.agit.konect.domain.notification.enums; + +public enum NotificationInboxType { + CLUB_APPLICATION_SUBMITTED, + CLUB_APPLICATION_APPROVED, + CLUB_APPLICATION_REJECTED +} diff --git a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java index 6c3d2ea7..43143483 100644 --- a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java +++ b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java @@ -27,12 +27,14 @@ public void handleClubApplicationApproved(ClubApplicationApprovedEvent event) { @TransactionalEventListener(phase = AFTER_COMMIT) public void handleClubApplicationSubmitted(ClubApplicationSubmittedEvent event) { - notificationService.sendClubApplicationSubmittedNotification( - event.receiverId(), - event.applicationId(), - event.clubId(), - event.clubName(), - event.applicantName() + event.receiverIds().forEach(receiverId -> + notificationService.sendClubApplicationSubmittedNotification( + receiverId, + event.applicationId(), + event.clubId(), + event.clubName(), + event.applicantName() + ) ); } } diff --git a/src/main/java/gg/agit/konect/domain/notification/model/NotificationInbox.java b/src/main/java/gg/agit/konect/domain/notification/model/NotificationInbox.java new file mode 100644 index 00000000..eea61b21 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/model/NotificationInbox.java @@ -0,0 +1,83 @@ +package gg.agit.konect.domain.notification.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notification_inbox") +@NoArgsConstructor(access = PROTECTED) +public class NotificationInbox extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(STRING) + @Column(name = "type", nullable = false, length = 50) + private NotificationInboxType type; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "body", nullable = false, length = 300) + private String body; + + @Column(name = "path", length = 200) + private String path; + + @Column(name = "is_read", nullable = false) + private Boolean isRead; + + @Builder + private NotificationInbox(User user, NotificationInboxType type, String title, String body, String path) { + this.user = user; + this.type = type; + this.title = title; + this.body = body; + this.path = path; + this.isRead = false; + } + + public static NotificationInbox of( + User user, + NotificationInboxType type, + String title, + String body, + String path + ) { + return NotificationInbox.builder() + .user(user) + .type(type) + .title(title) + .body(body) + .path(path) + .build(); + } + + public void markAsRead() { + this.isRead = true; + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java new file mode 100644 index 00000000..b9785bf9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java @@ -0,0 +1,35 @@ +package gg.agit.konect.domain.notification.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.global.exception.CustomException; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_NOTIFICATION_INBOX; + +public interface NotificationInboxRepository extends Repository { + + NotificationInbox save(NotificationInbox notificationInbox); + + Page findAllByUserIdOrderByCreatedAtDescIdDesc(Integer userId, Pageable pageable); + + long countByUserIdAndIsReadFalse(Integer userId); + + Optional findByIdAndUserId(Integer id, Integer userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE NotificationInbox n SET n.isRead = true WHERE n.user.id = :userId AND n.isRead = false") + void markAllAsReadByUserId(@Param("userId") Integer userId); + + default NotificationInbox getByIdAndUserId(Integer id, Integer userId) { + return findByIdAndUserId(id, userId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_NOTIFICATION_INBOX)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java new file mode 100644 index 00000000..44b513d4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java @@ -0,0 +1,66 @@ +package gg.agit.konect.domain.notification.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationInboxService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final NotificationInboxRepository notificationInboxRepository; + private final UserRepository userRepository; + private final NotificationInboxSseService notificationInboxSseService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void save(Integer userId, NotificationInboxType type, String title, String body, String path) { + try { + User user = userRepository.getById(userId); + NotificationInbox inbox = NotificationInbox.of(user, type, title, body, path); + NotificationInbox saved = notificationInboxRepository.save(inbox); + notificationInboxSseService.send(userId, NotificationInboxResponse.from(saved)); + } catch (Exception e) { + log.error("Failed to save notification inbox: userId={}, type={}", userId, type, e); + } + } + + public NotificationInboxesResponse getMyInboxes(Integer userId, int page) { + PageRequest pageable = PageRequest.of(page - 1, DEFAULT_PAGE_SIZE); + Page result = notificationInboxRepository + .findAllByUserIdOrderByCreatedAtDescIdDesc(userId, pageable); + return NotificationInboxesResponse.from(result); + } + + public NotificationInboxUnreadCountResponse getUnreadCount(Integer userId) { + long count = notificationInboxRepository.countByUserIdAndIsReadFalse(userId); + return NotificationInboxUnreadCountResponse.of(count); + } + + @Transactional + public void markAsRead(Integer userId, Integer notificationId) { + NotificationInbox inbox = notificationInboxRepository.getByIdAndUserId(notificationId, userId); + inbox.markAsRead(); + } + + @Transactional + public void markAllAsRead(Integer userId) { + notificationInboxRepository.markAllAsReadByUserId(userId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java new file mode 100644 index 00000000..4f357d06 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java @@ -0,0 +1,56 @@ +package gg.agit.konect.domain.notification.service; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class NotificationInboxSseService { + + private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L; + + private final Map emitters = new ConcurrentHashMap<>(); + + public SseEmitter subscribe(Integer userId) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + + emitter.onCompletion(() -> emitters.remove(userId, emitter)); + emitter.onTimeout(() -> emitters.remove(userId, emitter)); + emitter.onError(e -> emitters.remove(userId, emitter)); + + SseEmitter previous = emitters.put(userId, emitter); + if (previous != null) { + previous.complete(); + } + + try { + emitter.send(SseEmitter.event().name("connect").data("connected")); + } catch (IOException e) { + emitters.remove(userId, emitter); + emitter.completeWithError(e); + } + + return emitter; + } + + public void send(Integer userId, NotificationInboxResponse notification) { + SseEmitter emitter = emitters.get(userId); + if (emitter == null) { + return; + } + try { + emitter.send(SseEmitter.event().name("notification").data(notification)); + } catch (IOException e) { + log.warn("SSE send failed: userId={}", userId, e); + emitters.remove(userId, emitter); + emitter.completeWithError(e); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index c144733b..7e0b32d8 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -21,6 +21,7 @@ import gg.agit.konect.domain.notification.dto.NotificationTokenDeleteRequest; import gg.agit.konect.domain.notification.dto.NotificationTokenRegisterRequest; import gg.agit.konect.domain.notification.dto.NotificationTokenResponse; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.model.NotificationDeviceToken; import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; @@ -50,6 +51,7 @@ public class NotificationService { private final RestTemplate restTemplate; private final ChatPresenceService chatPresenceService; private final ExpoPushClient expoPushClient; + private final NotificationInboxService notificationInboxService; public NotificationTokenResponse getMyToken(Integer userId) { NotificationDeviceToken token = notificationDeviceTokenRepository.getByUserId(userId); @@ -312,17 +314,27 @@ public void sendClubApplicationSubmittedNotification( ) { String body = applicantName + "님이 동아리 가입을 신청했어요."; String path = "mypage/manager/" + clubId + "/applications/" + applicationId; + notificationInboxService.save( + receiverId, NotificationInboxType.CLUB_APPLICATION_SUBMITTED, clubName, body, path); sendNotification(receiverId, clubName, body, path); } @Async public void sendClubApplicationApprovedNotification(Integer receiverId, Integer clubId, String clubName) { - sendNotification(receiverId, clubName, "동아리 지원이 승인되었어요.", "clubs/" + clubId); + String body = "동아리 지원이 승인되었어요."; + String path = "clubs/" + clubId; + notificationInboxService.save( + receiverId, NotificationInboxType.CLUB_APPLICATION_APPROVED, clubName, body, path); + sendNotification(receiverId, clubName, body, path); } @Async public void sendClubApplicationRejectedNotification(Integer receiverId, Integer clubId, String clubName) { - sendNotification(receiverId, clubName, "동아리 지원이 거절되었어요.", "clubs/" + clubId); + String body = "동아리 지원이 거절되었어요."; + String path = "clubs/" + clubId; + notificationInboxService.save( + receiverId, NotificationInboxType.CLUB_APPLICATION_REJECTED, clubName, body, path); + sendNotification(receiverId, clubName, body, path); } private void sendNotification(Integer receiverId, String title, String body, String path) { diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index fe3750fb..1d5c7d2b 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -91,6 +91,7 @@ public enum ApiResponseCode { NOT_FOUND_BANK(HttpStatus.NOT_FOUND, "해당하는 은행을 찾을 수 없습니다."), NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), + NOT_FOUND_NOTIFICATION_INBOX(HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다."), NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), NOT_FOUND_GOOGLE_DRIVE_AUTH(HttpStatus.NOT_FOUND, "Google Drive 권한이 연결되지 않았습니다. 먼저 Drive 권한을 연결해 주세요."), diff --git a/src/main/resources/db/migration/V59__add_notification_inbox_table.sql b/src/main/resources/db/migration/V59__add_notification_inbox_table.sql new file mode 100644 index 00000000..c7adb604 --- /dev/null +++ b/src/main/resources/db/migration/V59__add_notification_inbox_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE notification_inbox +( + id INT NOT NULL AUTO_INCREMENT, + user_id INT NOT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(100) NOT NULL, + body VARCHAR(300) NOT NULL, + path VARCHAR(200), + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + INDEX idx_notification_inbox_user_id (user_id), + INDEX idx_notification_inbox_user_id_is_read (user_id, is_read), + CONSTRAINT fk_notification_inbox_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V60__fix_notification_inbox_table.sql b/src/main/resources/db/migration/V60__fix_notification_inbox_table.sql new file mode 100644 index 00000000..f46dba33 --- /dev/null +++ b/src/main/resources/db/migration/V60__fix_notification_inbox_table.sql @@ -0,0 +1,9 @@ +ALTER TABLE notification_inbox + MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; + +ALTER TABLE notification_inbox + ADD INDEX idx_notification_inbox_user_id_created_at (user_id, created_at DESC); + +ALTER TABLE notification_inbox + DROP INDEX idx_notification_inbox_user_id; diff --git a/src/main/resources/db/migration/V61__update_notification_inbox_index.sql b/src/main/resources/db/migration/V61__update_notification_inbox_index.sql new file mode 100644 index 00000000..05005897 --- /dev/null +++ b/src/main/resources/db/migration/V61__update_notification_inbox_index.sql @@ -0,0 +1,5 @@ +ALTER TABLE notification_inbox + DROP INDEX idx_notification_inbox_user_id_created_at; + +ALTER TABLE notification_inbox + ADD INDEX idx_notification_inbox_user_id_created_at_id (user_id, created_at DESC, id DESC); diff --git a/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java new file mode 100644 index 00000000..c0ef533b --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java @@ -0,0 +1,225 @@ +package gg.agit.konect.integration.domain.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class NotificationInboxApiTest extends IntegrationTestSupport { + + @Autowired + private NotificationInboxRepository notificationInboxRepository; + + private User user; + private User otherUser; + + @BeforeEach + void setUp() { + University university = persist(UniversityFixture.create()); + user = persist(UserFixture.createUser(university, "테스트유저", "2021136001")); + otherUser = persist(UserFixture.createUser(university, "다른유저", "2021136002")); + } + + private NotificationInbox createInbox(User owner, NotificationInboxType type, String title) { + return persist(NotificationInbox.of(owner, type, title, "테스트 본문입니다.", "clubs/1")); + } + + @Nested + @DisplayName("GET /notifications/inbox - 인앱 알림 목록 조회") + class GetMyInboxes { + + @Test + @DisplayName("알림 목록을 최신순으로 조회한다") + void getMyInboxesSuccess() throws Exception { + // given + NotificationInbox first = createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "동아리 승인"); + NotificationInbox second = createInbox(user, NotificationInboxType.CLUB_APPLICATION_REJECTED, "동아리 거절"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + // when & then + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications").isArray()) + .andExpect(jsonPath("$.notifications.length()").value(2)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.notifications[0].id").value(second.getId())) + .andExpect(jsonPath("$.notifications[1].id").value(first.getId())); + } + + @Test + @DisplayName("자신의 알림만 조회된다") + void getMyInboxesOnlyMine() throws Exception { + // given + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "내 알림"); + createInbox(otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + // when & then + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications.length()").value(1)) + .andExpect(jsonPath("$.notifications[0].title").value("내 알림")); + } + + @Test + @DisplayName("page=0으로 요청하면 400을 반환한다") + void getMyInboxesWithInvalidPageFails() throws Exception { + // given + mockLoginUser(user.getId()); + + // when & then + performGet("/notifications/inbox?page=0") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("알림이 없으면 빈 목록을 반환한다") + void getMyInboxesWhenEmptyReturnsEmptyList() throws Exception { + // given + mockLoginUser(user.getId()); + + // when & then + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications").isEmpty()) + .andExpect(jsonPath("$.totalElements").value(0)); + } + } + + @Nested + @DisplayName("GET /notifications/inbox/unread-count - 미읽음 알림 개수 조회") + class GetUnreadCount { + + @Test + @DisplayName("미읽음 알림 개수를 반환한다") + void getUnreadCountSuccess() throws Exception { + // given + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림2"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + // when & then + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(2)); + } + + @Test + @DisplayName("알림이 없으면 미읽음 개수가 0이다") + void getUnreadCountWhenNoneReturnsZero() throws Exception { + // given + mockLoginUser(user.getId()); + + // when & then + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(0)); + } + } + + @Nested + @DisplayName("PATCH /notifications/inbox/{id}/read - 단건 읽음 처리") + class MarkAsRead { + + @Test + @DisplayName("알림을 읽음 처리한다") + void markAsReadSuccess() throws Exception { + // given + NotificationInbox inbox = createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "읽을 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + // when & then + mockMvc.perform(patch("/notifications/inbox/" + inbox.getId() + "/read") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + clearPersistenceContext(); + assertThat(notificationInboxRepository.findByIdAndUserId(inbox.getId(), user.getId())) + .isPresent() + .get() + .extracting(NotificationInbox::getIsRead) + .isEqualTo(true); + } + + @Test + @DisplayName("다른 유저의 알림을 읽음 처리하면 404를 반환한다") + void markAsReadOtherUserInboxFails() throws Exception { + // given + NotificationInbox otherInbox = createInbox( + otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + // when & then + mockMvc.perform(patch("/notifications/inbox/" + otherInbox.getId() + "/read") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_NOTIFICATION_INBOX")); + } + } + + @Nested + @DisplayName("PATCH /notifications/inbox/read-all - 전체 읽음 처리") + class MarkAllAsRead { + + @Test + @DisplayName("자신의 모든 알림을 읽음 처리한다") + void markAllAsReadSuccess() throws Exception { + // given + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_SUBMITTED, "알림2"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + // when & then + mockMvc.perform(patch("/notifications/inbox/read-all") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + clearPersistenceContext(); + long unreadCount = notificationInboxRepository.countByUserIdAndIsReadFalse(user.getId()); + assertThat(unreadCount).isZero(); + } + + @Test + @DisplayName("전체 읽음 처리 후 미읽음 개수가 0이 된다") + void markAllAsReadUpdatesUnreadCount() throws Exception { + // given + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_REJECTED, "알림2"); + createInbox(otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + // when + mockMvc.perform(patch("/notifications/inbox/read-all") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + // then: 내 알림만 읽음 처리됨 + clearPersistenceContext(); + assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(user.getId())).isZero(); + assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(otherUser.getId())).isEqualTo(1L); + } + } +} From 6f5656cddc3c4a23b836b111cc51737258ee8392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:49:21 +0900 Subject: [PATCH 24/55] =?UTF-8?q?docs:=20=EC=9D=B8=EC=95=B1=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=EC=97=90=EC=84=9C=20=EC=9D=91=EB=8B=B5=20DTO?= =?UTF-8?q?=EC=97=90=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/NotificationInboxResponse.java | 14 ++++++++++++++ .../dto/NotificationInboxUnreadCountResponse.java | 7 ++++++- .../dto/NotificationInboxesResponse.java | 10 ++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java index 3e29c709..a28c4ed7 100644 --- a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java @@ -4,14 +4,28 @@ import gg.agit.konect.domain.notification.enums.NotificationInboxType; import gg.agit.konect.domain.notification.model.NotificationInbox; +import io.swagger.v3.oas.annotations.media.Schema; public record NotificationInboxResponse( + @Schema(description = "알림 ID", example = "1") Integer id, + + @Schema(description = "알림 타입", example = "CLUB_APPLICATION_SUBMITTED") NotificationInboxType type, + + @Schema(description = "알림 제목", example = "동아리 가입 신청이 접수되었습니다") String title, + + @Schema(description = "알림 내용", example = "신청하신 동아리의 가입 신청이 접수되었습니다") String body, + + @Schema(description = "알림 클릭 시 이동할 경로", example = "/clubs/1") String path, + + @Schema(description = "읽음 여부", example = "false") Boolean isRead, + + @Schema(description = "알림 생성 시간", example = "2024-01-15T10:30:00") LocalDateTime createdAt ) { public static NotificationInboxResponse from(NotificationInbox inbox) { diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java index 70d6fb45..7aa7e12c 100644 --- a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java @@ -1,6 +1,11 @@ package gg.agit.konect.domain.notification.dto; -public record NotificationInboxUnreadCountResponse(long unreadCount) { +import io.swagger.v3.oas.annotations.media.Schema; + +public record NotificationInboxUnreadCountResponse( + @Schema(description = "미읽은 알림 개수", example = "5") + long unreadCount +) { public static NotificationInboxUnreadCountResponse of(long count) { return new NotificationInboxUnreadCountResponse(count); } diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java index 8ea12670..667ab5d4 100644 --- a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java @@ -5,12 +5,22 @@ import org.springframework.data.domain.Page; import gg.agit.konect.domain.notification.model.NotificationInbox; +import io.swagger.v3.oas.annotations.media.Schema; public record NotificationInboxesResponse( + @Schema(description = "알림 목록") List notifications, + + @Schema(description = "현재 페이지 번호 (1부터 시작)", example = "1") int currentPage, + + @Schema(description = "총 페이지 수", example = "10") int totalPages, + + @Schema(description = "총 알림 개수", example = "100") long totalElements, + + @Schema(description = "다음 페이지 존재 여부", example = "true") boolean hasNext ) { public static NotificationInboxesResponse from(Page page) { From e4d1915b0f252e93b554395c6b87720440812355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:50:48 +0900 Subject: [PATCH 25/55] =?UTF-8?q?fix:=20=EC=9D=B8=EC=95=B1=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20SSE=20=EC=97=B0=EA=B2=B0=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#442)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅 메시지에 대한 인앱 알림도 추가 * test: 테스트 실패하는 문제 해결 * fix: SSE 요청 로깅에서 제외 * feat: 읽지 않은 쪽지에 대한 알림 타입 추가 --- .../enums/NotificationInboxType.java | 5 ++++- .../service/NotificationService.java | 22 +++++++++++++++++-- src/main/resources/application-monitoring.yml | 5 +++-- src/test/resources/application-test.yml | 8 +++++++ src/test/resources/test-credentials.json | 14 ++++++++++++ src/test/resources/test-key.p8 | 6 +++++ 6 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 src/test/resources/test-credentials.json create mode 100644 src/test/resources/test-key.p8 diff --git a/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java index ee708a39..e6b4b171 100644 --- a/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java +++ b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java @@ -3,5 +3,8 @@ public enum NotificationInboxType { CLUB_APPLICATION_SUBMITTED, CLUB_APPLICATION_APPROVED, - CLUB_APPLICATION_REJECTED + CLUB_APPLICATION_REJECTED, + CHAT_MESSAGE, + GROUP_CHAT_MESSAGE, + UNREAD_CHAT_COUNT } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index 7e0b32d8..d2c3d5e0 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -99,15 +99,25 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send return; } + String truncatedBody = buildPreview(messageContent); + String path = "chats/" + roomId; + + notificationInboxService.save( + receiverId, + NotificationInboxType.CHAT_MESSAGE, + senderName, + truncatedBody, + path + ); + List tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId); if (tokens.isEmpty()) { log.debug("No device tokens found for user: receiverId={}", receiverId); return; } - String truncatedBody = buildPreview(messageContent); Map data = new HashMap<>(); - data.put("path", "chats/" + roomId); + data.put("path", path); List messages = tokens.stream() .map(token -> new ExpoPushMessage( @@ -224,6 +234,14 @@ public void sendGroupChatNotification( continue; } + notificationInboxService.save( + recipientId, + NotificationInboxType.GROUP_CHAT_MESSAGE, + clubName, + previewBody, + "chats/" + roomId + ); + List tokens = notificationDeviceTokenRepository.findTokensByUserId(recipientId); if (tokens.isEmpty()) { log.debug("No device tokens found for user: recipientId={}", recipientId); diff --git a/src/main/resources/application-monitoring.yml b/src/main/resources/application-monitoring.yml index 7215d9a9..4ec70381 100644 --- a/src/main/resources/application-monitoring.yml +++ b/src/main/resources/application-monitoring.yml @@ -2,9 +2,10 @@ logging: ignored-url-patterns: - /**/api-docs/** - /**/swagger-ui/** - - /error, - - /favicon.ico, + - /error + - /favicon.ico - /actuator/** + - /notifications/inbox/stream management: endpoints: diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 30c00576..9ee86295 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -129,6 +129,14 @@ claude: mcp: url: http://localhost:3100 +google: + sheets: + credentials-path: classpath:test-credentials.json + application-name: test-app + oauth-client-id: test-google-sheets-client-id + oauth-client-secret: test-google-sheets-client-secret + oauth-callback-base-url: http://localhost:8080 + logging: ignored-url-patterns: - /**/api-docs/** diff --git a/src/test/resources/test-credentials.json b/src/test/resources/test-credentials.json new file mode 100644 index 00000000..da248032 --- /dev/null +++ b/src/test/resources/test-credentials.json @@ -0,0 +1,14 @@ +{ + "type": "service_account", + "project_id": "test-project", + "private_key_id": "test-key-id", + "private_key": "-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtest +-----END PRIVATE KEY----- +", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs" +} diff --git a/src/test/resources/test-key.p8 b/src/test/resources/test-key.p8 new file mode 100644 index 00000000..caeda779 --- /dev/null +++ b/src/test/resources/test-key.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgTestKeyForTesting +PurposesOnlyNotARealKeyDoNotUseInProductionAAAAAAAAAAAAAAMCgYIKoZI +zj0DAQehRANCAATTestKeyForTestingPurposesOnlyNotARealKeyDoNotUseIn +ProductionTestKeyForTestingPurposesOnly +-----END PRIVATE KEY----- From 2e75b9e74e5ba835b3d8eab534756209fc2f06b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:00:54 +0900 Subject: [PATCH 26/55] =?UTF-8?q?build:=20JDK=20=EB=B2=84=EC=A0=84=2017=20?= =?UTF-8?q?->=2021=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C=20(#443?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checkstyle.yml | 4 ++-- .github/workflows/deploy-prod.yml | 4 ++-- .github/workflows/deploy-stage.yml | 4 ++-- Dockerfile | 2 +- build.gradle | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/checkstyle.yml b/.github/workflows/checkstyle.yml index 0260a845..804f4363 100644 --- a/.github/workflows/checkstyle.yml +++ b/.github/workflows/checkstyle.yml @@ -26,10 +26,10 @@ jobs: submodules: recursive token: ${{ secrets.SUBMODULE_TOKEN }} - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Cache Gradle packages diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 8e8d7fb3..3f9088bd 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -13,10 +13,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Cache Gradle packages diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 23ae9e4d..44e43bc2 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -13,10 +13,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Cache Gradle packages diff --git a/Dockerfile b/Dockerfile index 97fa9391..11cab8f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM amazoncorretto:17-alpine +FROM amazoncorretto:21-alpine ARG OTEL_JAVA_AGENT_VERSION=2.18.1 diff --git a/build.gradle b/build.gradle index 2ebbaea0..1b34a555 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } From 617db71c839611b42228cdd6c070e9a15c3499db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:06:50 +0900 Subject: [PATCH 27/55] =?UTF-8?q?fix:=20=EC=9D=B8=ED=84=B0=EC=85=89?= =?UTF-8?q?=ED=84=B0=20=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 인터셉터 예외가 GlobalExceptionHandler에서 처리되도록 수정 * chore: 코드 포맷팅 * fix: 인터셉터 HandlerExceptionResolver 순환 참조 해결 --- .../auth/web/AuthorizationInterceptor.java | 37 +++++++++++++------ .../auth/web/LoginCheckInterceptor.java | 34 +++++++++++++---- .../agit/konect/global/config/WebConfig.java | 1 - .../web/AuthorizationInterceptorTest.java | 6 ++- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java b/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java index 389c2fb4..91cc4b1f 100644 --- a/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java +++ b/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java @@ -1,9 +1,11 @@ package gg.agit.konect.global.auth.web; +import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.HandlerInterceptor; import gg.agit.konect.domain.user.model.User; @@ -13,20 +15,29 @@ import gg.agit.konect.global.exception.CustomException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; /** * 권한 체크 인터셉터. - * @Auth 어노테이션이 있는 경우 사용자의 역할(Role)을 검증 + * @Auth 어노테이션이 있는 경우 사용자의 역할(Role)을 검증합니다. + * 예외 발생 시 HandlerExceptionResolver를 통해 GlobalExceptionHandler로 위임합니다. */ @Component -@RequiredArgsConstructor public class AuthorizationInterceptor implements HandlerInterceptor { private final UserRepository userRepository; + private final HandlerExceptionResolver handlerExceptionResolver; + + public AuthorizationInterceptor( + UserRepository userRepository, + @Lazy HandlerExceptionResolver handlerExceptionResolver + ) { + this.userRepository = userRepository; + this.handlerExceptionResolver = handlerExceptionResolver; + } @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws + Exception { if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } @@ -45,15 +56,19 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - Object userId = request.getAttribute(LoginCheckInterceptor.AUTHENTICATED_USER_ID_ATTRIBUTE); - - if (!(userId instanceof Integer id)) { - throw CustomException.of(ApiResponseCode.MISSING_ACCESS_TOKEN); - } + try { + Object userId = request.getAttribute(LoginCheckInterceptor.AUTHENTICATED_USER_ID_ATTRIBUTE); - validateRole(id, auth); + if (!(userId instanceof Integer id)) { + throw CustomException.of(ApiResponseCode.MISSING_ACCESS_TOKEN); + } - return true; + validateRole(id, auth); + return true; + } catch (CustomException e) { + handlerExceptionResolver.resolveException(request, response, handler, e); + return false; + } } private Auth findAuthAnnotation(HandlerMethod handlerMethod) { diff --git a/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java b/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java index 543ca29c..00ef5313 100644 --- a/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java +++ b/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java @@ -1,24 +1,27 @@ package gg.agit.konect.global.auth.web; +import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.HandlerInterceptor; import gg.agit.konect.global.auth.jwt.JwtProvider; import gg.agit.konect.global.auth.annotation.PublicApi; +import gg.agit.konect.global.exception.CustomException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; /** * 로그인 체크 인터셉터. * JWT 액세스 토큰을 검증하고 인증된 사용자 ID를 request attribute에 설정합니다. * @PublicApi 어노테이션이 있는 경우 인증을 건너뜁니다. + * + * 예외 발생 시 HandlerExceptionResolver를 통해 GlobalExceptionHandler로 위임합니다. */ @Component -@RequiredArgsConstructor public class LoginCheckInterceptor implements HandlerInterceptor { public static final String AUTHENTICATED_USER_ID_ATTRIBUTE = "authenticatedUserId"; @@ -27,9 +30,19 @@ public class LoginCheckInterceptor implements HandlerInterceptor { private static final String BEARER_PREFIX = "Bearer "; private final JwtProvider jwtProvider; + private final HandlerExceptionResolver handlerExceptionResolver; + + public LoginCheckInterceptor( + JwtProvider jwtProvider, + @Lazy HandlerExceptionResolver handlerExceptionResolver + ) { + this.jwtProvider = jwtProvider; + this.handlerExceptionResolver = handlerExceptionResolver; + } @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws + Exception { if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } @@ -43,11 +56,16 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - String accessToken = resolveBearerToken(request); - Integer userId = jwtProvider.getUserId(accessToken); - request.setAttribute(AUTHENTICATED_USER_ID_ATTRIBUTE, userId); - - return true; + try { + String accessToken = resolveBearerToken(request); + Integer userId = jwtProvider.getUserId(accessToken); + request.setAttribute(AUTHENTICATED_USER_ID_ATTRIBUTE, userId); + return true; + } catch (CustomException e) { + // GlobalExceptionHandler가 처리하도록 위임 + handlerExceptionResolver.resolveException(request, response, handler, e); + return false; + } } private boolean isPublicEndpoint(HandlerMethod handlerMethod) { diff --git a/src/main/java/gg/agit/konect/global/config/WebConfig.java b/src/main/java/gg/agit/konect/global/config/WebConfig.java index c7058bf3..42d6ba71 100644 --- a/src/main/java/gg/agit/konect/global/config/WebConfig.java +++ b/src/main/java/gg/agit/konect/global/config/WebConfig.java @@ -56,5 +56,4 @@ public void addInterceptors(InterceptorRegistry registry) { public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/login").setViewName("forward:/login.html"); } - } diff --git a/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java index f4856d8a..9d80b111 100644 --- a/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java +++ b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java @@ -14,6 +14,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.servlet.HandlerExceptionResolver; import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; @@ -29,11 +30,14 @@ class AuthorizationInterceptorTest { @Mock private UserRepository userRepository; + @Mock + private HandlerExceptionResolver handlerExceptionResolver; + private AuthorizationInterceptor interceptor; @BeforeEach void setUp() { - interceptor = new AuthorizationInterceptor(userRepository); + interceptor = new AuthorizationInterceptor(userRepository, handlerExceptionResolver); } @Nested From 38885480dfaf869400b4940e62da498e04423526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:27:28 +0900 Subject: [PATCH 28/55] =?UTF-8?q?refactor:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=86=8D=EB=8F=84=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#447)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 그룹 채팅 알림 일괄 발송으로 성능 개선 * chore: 매직넘버 상수화 * refactor: 채팅방 접속 사용자 조회 Redis multiGet 적용 * refactor: 인앱 알림 추가 일괄 처리 * refactor: 음소거 사용자 조회 쿼리 최적화 * refactor: 알림함 저장과 SSE 전송 분리로 트랜잭션 경계 명확화 * fix: 알림함 저장 트랜잭션 전파 옵션 REQUIRES_NEW로 변경 * chore: 코드 포맷팅 * refactor: Object[] 캐스팅 제거를 위해 UserIdTokenProjection 적용 * refactor: 불필요한 그룹핑 제거로 푸시 알림 메시지 생성 단순화 * fix: 누락된 SSE 전송 추가 * fix: 삭제된 유저에게 푸시 알림 발송되지 않도록 토큰 조회 쿼리에 deletedAt 조건 추가 * fix: sendBatchNotifications 재시도 소진 시 @Recover 메서드 추가 * fix: detached 엔티티 LAZY 로딩 문제 해결을 위해 userId를 직접 전달 * fix: 미사용 상수, 레코드 제거 * fix: 배치 단위 재시도로 중복 푸시 방지 * fix: sendSseBatch 인덱스 불일치 문제 해결 --- .../chat/service/ChatPresenceService.java | 33 ++- .../NotificationDeviceTokenRepository.java | 9 + .../NotificationInboxRepository.java | 3 + .../NotificationMuteSettingRepository.java | 15 + .../notification/service/ExpoPushClient.java | 127 ++++++++- .../service/NotificationInboxService.java | 42 ++- .../service/NotificationService.java | 262 +++++------------- 7 files changed, 284 insertions(+), 207 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java index 956a4d85..9882f358 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java @@ -1,6 +1,9 @@ package gg.agit.konect.domain.chat.service; import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -53,13 +56,29 @@ public boolean isUserInChatRoom(Integer roomId, Integer userId) { return redis.opsForValue().get(key) != null; } - /** - * Redis 키를 생성합니다. - * - * @param roomId 채팅방 ID - * @param userId 사용자 ID - * @return Redis 키 (형식: chat:presence:room:{roomId}:user:{userId}) - */ + public Set findUsersInChatRoom(Integer roomId, List userIds) { + if (roomId == null || userIds == null || userIds.isEmpty()) { + return Set.of(); + } + + List keys = userIds.stream() + .map(userId -> presenceKey(roomId, userId)) + .toList(); + + List values = redis.opsForValue().multiGet(keys); + if (values == null) { + return Set.of(); + } + + Set activeUsers = new HashSet<>(); + for (int i = 0; i < userIds.size(); i++) { + if (values.get(i) != null) { + activeUsers.add(userIds.get(i)); + } + } + return activeUsers; + } + private String presenceKey(Integer roomId, Integer userId) { return PRESENCE_PREFIX + roomId + USER_SUFFIX + userId; } diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java index df221ab8..bd5f8909 100644 --- a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java @@ -35,9 +35,18 @@ Optional findByUserIdAndToken( SELECT ndt.token FROM NotificationDeviceToken ndt WHERE ndt.user.id = :userId + AND ndt.user.deletedAt IS NULL """) List findTokensByUserId(@Param("userId") Integer userId); + @Query(""" + SELECT ndt.token + FROM NotificationDeviceToken ndt + WHERE ndt.user.id IN :userIds + AND ndt.user.deletedAt IS NULL + """) + List findTokensByUserIds(@Param("userIds") List userIds); + void save(NotificationDeviceToken notificationDeviceToken); void delete(NotificationDeviceToken notificationDeviceToken); diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java index b9785bf9..5323e5f4 100644 --- a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.notification.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -18,6 +19,8 @@ public interface NotificationInboxRepository extends Repository saveAll(Iterable notificationInboxes); + Page findAllByUserIdOrderByCreatedAtDescIdDesc(Integer userId, Pageable pageable); long countByUserIdAndIsReadFalse(Integer userId); diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java index f85e5fd7..01bd1f52 100644 --- a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -51,4 +52,18 @@ List findByTargetTypeAndTargetIdAndIsMutedTrue( @Param("targetType") NotificationTargetType targetType, @Param("targetId") Integer targetId ); + + @Query(""" + SELECT s.user.id + FROM NotificationMuteSetting s + WHERE s.targetType = :targetType + AND s.targetId = :targetId + AND s.user.id IN :userIds + AND s.isMuted = true + """) + Set findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + @Param("targetType") NotificationTargetType targetType, + @Param("targetId") Integer targetId, + @Param("userIds") List userIds + ); } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java index 50e28017..82b5d690 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.notification.service; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -25,6 +26,7 @@ public class ExpoPushClient { private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default_notifications"; + private static final int BATCH_SIZE = 100; private final RestTemplate expoRestTemplate; @@ -84,6 +86,73 @@ public void sendNotification(Integer receiverId, List tokens, String tit log.debug("알림 발송 완료: receiverId={}, tokenCount={}", receiverId, tokens.size()); } + public void sendBatchNotifications(List messages) { + if (messages == null || messages.isEmpty()) { + return; + } + + List> batches = partition(messages, BATCH_SIZE); + + for (List batch : batches) { + sendSingleBatch(batch); + } + + log.debug("배치 알림 발송 완료: messageCount={}", messages.size()); + } + + @Retryable(maxAttempts = 2) + public void sendSingleBatch(List batch) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + + HttpEntity> entity = new HttpEntity<>(batch, headers); + ResponseEntity response = expoRestTemplate.exchange( + EXPO_PUSH_URL, + HttpMethod.POST, + entity, + ExpoPushResponse.class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new IllegalStateException( + "Expo push batch response not successful: status=%s" + .formatted(response.getStatusCode()) + ); + } + + ExpoPushResponse responseBody = response.getBody(); + if (responseBody == null || responseBody.data() == null) { + throw new IllegalStateException("Expo push batch response body missing"); + } + + for (int i = 0; i < responseBody.data().size(); i += 1) { + ExpoPushTicket ticket = responseBody.data().get(i); + if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { + continue; + } + String token = i < batch.size() ? batch.get(i).to() : "unknown"; + log.error( + "Expo 푸시 배치 발송 실패: token={}, status={}, message={}, details={}", + token, + ticket.status(), + ticket.message(), + ticket.details() + ); + } + } + + private List> partition(List list, int size) { + List> partitions = new ArrayList<>(); + for (int i = 0; i < list.size(); i += size) { + partitions.add(list.subList(i, Math.min(i + size, list.size()))); + } + return partitions; + } + + public record ExpoPushMessage(String to, String title, String body, Map data, String channelId) { + } + @Recover public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverId, List tokens, String title, @@ -142,6 +211,61 @@ public void sendNotificationRecover(RestClientException e, Integer receiverId, L ); } + @Recover + public void sendSingleBatchRecover(HttpStatusCodeException e, List batch) { + log.error( + "배치 알림 재시도 후에도 HTTP 오류로 발송에 실패했습니다: batchSize={}, statusCode={}, responseBody={}", + batch.size(), + e.getStatusCode(), + e.getResponseBodyAsString(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(ResourceAccessException e, List batch) { + Throwable rootCause = e.getMostSpecificCause(); + log.error( + "배치 알림 재시도 후에도 연결 문제로 발송에 실패했습니다: batchSize={}, rootCauseType={}, rootCauseMessage={}", + batch.size(), + rootCause.getClass().getSimpleName(), + rootCause.getMessage(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(IllegalStateException e, List batch) { + log.error( + "배치 알림 재시도 후에도 Expo 응답이 비정상이라 발송에 실패했습니다: batchSize={}, message={}", + batch.size(), + e.getMessage(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(RestClientException e, List batch) { + log.error( + "배치 알림 재시도 후에도 Rest 클라이언트 오류로 발송에 실패했습니다: batchSize={}, exceptionType={}, message={}", + batch.size(), + e.getClass().getSimpleName(), + e.getMessage(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(Exception e, List batch) { + log.error( + "배치 알림 재시도 후에도 예기치 못한 오류로 발송에 실패했습니다: batchSize={}, exceptionType={}, message={}", + batch.size(), + e.getClass().getSimpleName(), + e.getMessage(), + e + ); + } + @Recover public void sendNotificationRecover(Exception e, Integer receiverId, List tokens, String title, String body, Map data) { @@ -155,9 +279,6 @@ public void sendNotificationRecover(Exception e, Integer receiverId, List data, String channelId) { - } - private record ExpoPushResponse(List data) { } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java index 44b513d4..42e1c967 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java @@ -1,5 +1,7 @@ package gg.agit.konect.domain.notification.service; +import java.util.List; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -30,14 +32,42 @@ public class NotificationInboxService { private final NotificationInboxSseService notificationInboxSseService; @Transactional(propagation = Propagation.REQUIRES_NEW) - public void save(Integer userId, NotificationInboxType type, String title, String body, String path) { + public NotificationInbox save(Integer userId, NotificationInboxType type, String title, String body, String path) { + User user = userRepository.getById(userId); + return notificationInboxRepository.save(NotificationInbox.of(user, type, title, body, path)); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List saveAll( + List userIds, + NotificationInboxType type, + String title, + String body, + String path + ) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + + List users = userRepository.findAllByIdIn(userIds); + List inboxes = users.stream() + .map(user -> NotificationInbox.of(user, type, title, body, path)) + .toList(); + + return notificationInboxRepository.saveAll(inboxes); + } + + public void sendSse(Integer userId, NotificationInboxResponse response) { try { - User user = userRepository.getById(userId); - NotificationInbox inbox = NotificationInbox.of(user, type, title, body, path); - NotificationInbox saved = notificationInboxRepository.save(inbox); - notificationInboxSseService.send(userId, NotificationInboxResponse.from(saved)); + notificationInboxSseService.send(userId, response); } catch (Exception e) { - log.error("Failed to save notification inbox: userId={}, type={}", userId, type, e); + log.warn("Failed to send SSE notification: userId={}", userId, e); + } + } + + public void sendSseBatch(List inboxes) { + for (NotificationInbox inbox : inboxes) { + sendSse(inbox.getUser().getId(), NotificationInboxResponse.from(inbox)); } } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index d2c3d5e0..0fe7fcd2 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -5,27 +5,24 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; -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.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; import gg.agit.konect.domain.notification.dto.NotificationTokenDeleteRequest; import gg.agit.konect.domain.notification.dto.NotificationTokenRegisterRequest; import gg.agit.konect.domain.notification.dto.NotificationTokenResponse; import gg.agit.konect.domain.notification.enums.NotificationInboxType; import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.model.NotificationDeviceToken; -import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.model.NotificationInbox; import gg.agit.konect.domain.notification.repository.NotificationDeviceTokenRepository; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; @@ -38,7 +35,6 @@ @Transactional(readOnly = true) public class NotificationService { - private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; private static final Pattern EXPO_PUSH_TOKEN_PATTERN = Pattern.compile("^(ExponentPushToken|ExpoPushToken)\\[[^\\]]+\\]$"); private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default_notifications"; @@ -48,7 +44,6 @@ public class NotificationService { private final UserRepository userRepository; private final NotificationDeviceTokenRepository notificationDeviceTokenRepository; private final NotificationMuteSettingRepository notificationMuteSettingRepository; - private final RestTemplate restTemplate; private final ChatPresenceService chatPresenceService; private final ExpoPushClient expoPushClient; private final NotificationInboxService notificationInboxService; @@ -102,7 +97,7 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send String truncatedBody = buildPreview(messageContent); String path = "chats/" + roomId; - notificationInboxService.save( + NotificationInbox saved = notificationInboxService.save( receiverId, NotificationInboxType.CHAT_MESSAGE, senderName, @@ -110,6 +105,8 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send path ); + notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); + List tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId); if (tokens.isEmpty()) { log.debug("No device tokens found for user: receiverId={}", receiverId); @@ -119,62 +116,7 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send Map data = new HashMap<>(); data.put("path", path); - List messages = tokens.stream() - .map(token -> new ExpoPushMessage( - token, senderName, truncatedBody, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) - .toList(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - - HttpEntity> entity = new HttpEntity<>(messages, headers); - ResponseEntity response = restTemplate.exchange( - EXPO_PUSH_URL, - HttpMethod.POST, - entity, - ExpoPushResponse.class - ); - - if (!response.getStatusCode().is2xxSuccessful()) { - log.error( - "Expo push response not successful: roomId={}, receiverId={}, status={}", - roomId, - receiverId, - response.getStatusCode() - ); - return; - } - - ExpoPushResponse body = response.getBody(); - if (body == null || body.data == null) { - log.error("Expo push response body missing: roomId={}, receiverId={}", roomId, receiverId); - return; - } - - for (int i = 0; i < body.data.size(); i += 1) { - ExpoPushTicket ticket = body.data.get(i); - if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { - continue; - } - String token = i < tokens.size() ? tokens.get(i) : "unknown"; - log.error( - "Expo push failed: roomId={}, receiverId={}, token={}, status={}, message={}, details={}", - roomId, - receiverId, - token, - ticket.status(), - ticket.message(), - ticket.details() - ); - } - - log.debug( - "Chat notification sent: roomId={}, receiverId={}, tokenCount={}", - roomId, - receiverId, - tokens.size() - ); + expoPushClient.sendNotification(receiverId, tokens, senderName, truncatedBody, data); } catch (Exception e) { log.error("Failed to send chat notification: roomId={}, receiverId={}", roomId, receiverId, e); } @@ -200,123 +142,67 @@ public void sendGroupChatNotification( return; } + // 채팅방에 접속하고 있는 유저 목록 + Set activeUsers = chatPresenceService.findUsersInChatRoom(roomId, filteredRecipients); + + // 채팅방 알림을 뮤트 처리한 유저 목록 + Set mutedUsers = notificationMuteSettingRepository + .findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, roomId, filteredRecipients); + + List targetRecipients = filteredRecipients.stream() + .filter(id -> !activeUsers.contains(id)) + .filter(id -> !mutedUsers.contains(id)) + .toList(); + + if (targetRecipients.isEmpty()) { + log.info( + "Group chat notification completed: roomId={}, totalRecipients={}, active={}, muted={}, target=0", + roomId, + filteredRecipients.size(), + activeUsers.size(), + mutedUsers.size() + ); + return; + } + String truncatedBody = buildPreview(messageContent); String previewBody = senderName + ": " + truncatedBody; + String path = "chats/" + roomId; + + List savedInboxes = notificationInboxService.saveAll( + targetRecipients, + NotificationInboxType.GROUP_CHAT_MESSAGE, + clubName, + previewBody, + path + ); + + notificationInboxService.sendSseBatch(savedInboxes); + + List tokens = notificationDeviceTokenRepository.findTokensByUserIds(targetRecipients); + Map data = new HashMap<>(); - data.put("path", "chats/" + roomId); - - for (Integer recipientId : filteredRecipients) { - try { - // 사용자가 현재 채팅방에 접속 중인 경우 알림 전송 생략 - if (chatPresenceService.isUserInChatRoom(roomId, recipientId)) { - log.debug( - "User in group chat room, skipping notification: roomId={}, recipientId={}", - roomId, - recipientId - ); - continue; - } - - boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( - NotificationTargetType.CHAT_ROOM, - roomId, - recipientId - ) - .map(setting -> Boolean.TRUE.equals(setting.getIsMuted())) - .orElse(false); - - if (isMuted) { - log.debug( - "Group chat muted, skipping notification: roomId={}, recipientId={}", - roomId, - recipientId - ); - continue; - } - - notificationInboxService.save( - recipientId, - NotificationInboxType.GROUP_CHAT_MESSAGE, - clubName, - previewBody, - "chats/" + roomId - ); - - List tokens = notificationDeviceTokenRepository.findTokensByUserId(recipientId); - if (tokens.isEmpty()) { - log.debug("No device tokens found for user: recipientId={}", recipientId); - continue; - } - - List messages = tokens.stream() - .map(token -> new ExpoPushMessage( - token, clubName, previewBody, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) - .toList(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - - HttpEntity> entity = new HttpEntity<>(messages, headers); - ResponseEntity response = restTemplate.exchange( - EXPO_PUSH_URL, - HttpMethod.POST, - entity, - ExpoPushResponse.class - ); - - if (!response.getStatusCode().is2xxSuccessful()) { - log.error( - "Expo push response not successful: roomId={}, recipientId={}, status={}", - roomId, - recipientId, - response.getStatusCode() - ); - continue; - } - - ExpoPushResponse body = response.getBody(); - if (body == null || body.data == null) { - log.error( - "Expo push response body missing: roomId={}, recipientId={}", - roomId, - recipientId - ); - continue; - } - - for (int i = 0; i < body.data.size(); i += 1) { - ExpoPushTicket ticket = body.data.get(i); - if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { - continue; - } - String token = i < tokens.size() ? tokens.get(i) : "unknown"; - log.error( - "Expo push failed: roomId={}, recipientId={}, token={}, status={}, message={}, details={}", - roomId, - recipientId, - token, - ticket.status(), - ticket.message(), - ticket.details() - ); - } - - log.debug( - "Group chat notification sent: roomId={}, recipientId={}, tokenCount={}", - roomId, - recipientId, - tokens.size() - ); - } catch (Exception e) { - log.error( - "Failed to send group chat notification to recipient: roomId={}, recipientId={}", - roomId, - recipientId, - e - ); - } + data.put("path", path); + + List messages = tokens.stream() + .map(token -> new ExpoPushClient.ExpoPushMessage( + token, clubName, previewBody, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) + .toList(); + + if (!messages.isEmpty()) { + expoPushClient.sendBatchNotifications(messages); } + + log.info( + "Group chat notification completed: roomId={}, total={}, active={}, muted={}, target={}, tokens={}", + roomId, + filteredRecipients.size(), + activeUsers.size(), + mutedUsers.size(), + targetRecipients.size(), + messages.size() + ); } catch (Exception e) { log.error("Failed to send group chat notification: roomId={}, senderId={}", roomId, senderId, e); } @@ -332,8 +218,9 @@ public void sendClubApplicationSubmittedNotification( ) { String body = applicantName + "님이 동아리 가입을 신청했어요."; String path = "mypage/manager/" + clubId + "/applications/" + applicationId; - notificationInboxService.save( + NotificationInbox saved = notificationInboxService.save( receiverId, NotificationInboxType.CLUB_APPLICATION_SUBMITTED, clubName, body, path); + notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); sendNotification(receiverId, clubName, body, path); } @@ -341,8 +228,9 @@ public void sendClubApplicationSubmittedNotification( public void sendClubApplicationApprovedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 승인되었어요."; String path = "clubs/" + clubId; - notificationInboxService.save( + NotificationInbox saved = notificationInboxService.save( receiverId, NotificationInboxType.CLUB_APPLICATION_APPROVED, clubName, body, path); + notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); sendNotification(receiverId, clubName, body, path); } @@ -350,8 +238,9 @@ public void sendClubApplicationApprovedNotification(Integer receiverId, Integer public void sendClubApplicationRejectedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 거절되었어요."; String path = "clubs/" + clubId; - notificationInboxService.save( + NotificationInbox saved = notificationInboxService.save( receiverId, NotificationInboxType.CLUB_APPLICATION_REJECTED, clubName, body, path); + notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); sendNotification(receiverId, clubName, body, path); } @@ -391,18 +280,9 @@ private String buildPreview(String messageContent) { return messageContent.substring(0, endIndex) + CHAT_MESSAGE_PREVIEW_SUFFIX; } - private record ExpoPushMessage(String to, String title, String body, Map data, String channelId) { - } - - private record ExpoPushResponse(List data) { - } - private void validateExpoToken(String token) { if (!EXPO_PUSH_TOKEN_PATTERN.matcher(token).matches()) { throw CustomException.of(INVALID_NOTIFICATION_TOKEN); } } - - private record ExpoPushTicket(String status, String message, Map details) { - } } From 6c81e4d42e25c500293f8f706d952d7ba2282bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:51:55 +0900 Subject: [PATCH 29/55] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=95=B1=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9D=98=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=A0=84=ED=8C=8C=20=EC=98=B5=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 트랜잭션 전파 옵션 제거 * fix: 비동기 알림 메서드에 트랜잭션을 명시해 readOnly 영향 제거 * fix: 승인·거절 알림 메서드에 트랜잭션을 추가 --- .../notification/service/NotificationInboxService.java | 7 +++---- .../domain/notification/service/NotificationService.java | 5 +++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java index 42e1c967..6bf3d071 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java @@ -5,12 +5,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; -import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; import gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; import gg.agit.konect.domain.notification.enums.NotificationInboxType; import gg.agit.konect.domain.notification.model.NotificationInbox; import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; @@ -31,13 +30,13 @@ public class NotificationInboxService { private final UserRepository userRepository; private final NotificationInboxSseService notificationInboxSseService; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public NotificationInbox save(Integer userId, NotificationInboxType type, String title, String body, String path) { User user = userRepository.getById(userId); return notificationInboxRepository.save(NotificationInbox.of(user, type, title, body, path)); } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public List saveAll( List userIds, NotificationInboxType type, diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index 0fe7fcd2..a597771e 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -74,6 +74,7 @@ public void deleteToken(Integer userId, NotificationTokenDeleteRequest request) } @Async + @Transactional public void sendChatNotification(Integer receiverId, Integer roomId, String senderName, String messageContent) { try { if (chatPresenceService.isUserInChatRoom(roomId, receiverId)) { @@ -123,6 +124,7 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send } @Async + @Transactional public void sendGroupChatNotification( Integer roomId, Integer senderId, @@ -209,6 +211,7 @@ public void sendGroupChatNotification( } @Async + @Transactional public void sendClubApplicationSubmittedNotification( Integer receiverId, Integer applicationId, @@ -225,6 +228,7 @@ public void sendClubApplicationSubmittedNotification( } @Async + @Transactional public void sendClubApplicationApprovedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 승인되었어요."; String path = "clubs/" + clubId; @@ -235,6 +239,7 @@ public void sendClubApplicationApprovedNotification(Integer receiverId, Integer } @Async + @Transactional public void sendClubApplicationRejectedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 거절되었어요."; String path = "clubs/" + clubId; From 7d60fe39107ee0f192b0d2a60b4db1e6ed4ff412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:46:48 +0900 Subject: [PATCH 30/55] =?UTF-8?q?fix:=20=EA=B4=91=EA=B3=A0=20=EB=9E=9C?= =?UTF-8?q?=EB=8D=A4=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=B4=20=EC=BF=BC=EB=A6=AC=20=ED=9A=9F=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EC=A4=84=EC=9E=84=20(#449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 광고 랜덤 조회의 반복 단건 조회를 제거해 쿼리 횟수를 줄임 * chore: 코드 포맷팅 --- .../repository/AdvertisementRepository.java | 3 --- .../service/AdvertisementService.java | 26 +++++++++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java index d88ee483..46de3b53 100644 --- a/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java +++ b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java @@ -22,9 +22,6 @@ public interface AdvertisementRepository extends Repository findAllByIsVisibleTrueOrderByCreatedAtDesc(); - @Query("SELECT a.id FROM Advertisement a WHERE a.isVisible = true") - List findAllVisibleIds(); - void delete(Advertisement advertisement); /** diff --git a/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java index 7328a2da..ea531f65 100644 --- a/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java +++ b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.ThreadLocalRandom; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,29 +26,28 @@ public AdvertisementService(AdvertisementRepository advertisementRepository) { } public AdvertisementsResponse getRandomAdvertisements(int count) { - List visibleIds = advertisementRepository.findAllVisibleIds(); + List visibleAdvertisements = + advertisementRepository.findAllByIsVisibleTrueOrderByCreatedAtDesc(); - if (visibleIds.isEmpty()) { + if (visibleAdvertisements.isEmpty()) { return AdvertisementsResponse.from(List.of()); } - List selectedIds = new ArrayList<>(); + List selectedAdvertisements = new ArrayList<>(); - if (visibleIds.size() >= count) { - Collections.shuffle(visibleIds); - selectedIds.addAll(visibleIds.subList(0, count)); + if (visibleAdvertisements.size() >= count) { + List shuffledAdvertisements = new ArrayList<>(visibleAdvertisements); + Collections.shuffle(shuffledAdvertisements); + selectedAdvertisements.addAll(shuffledAdvertisements.subList(0, count)); } else { for (int i = 0; i < count; i++) { - int randomIndex = (int)(Math.random() * visibleIds.size()); - selectedIds.add(visibleIds.get(randomIndex)); + // 등록된 노출 광고 수보다 많은 개수를 요청하면 기존 정책대로 중복 선택을 허용한다. + int randomIndex = ThreadLocalRandom.current().nextInt(visibleAdvertisements.size()); + selectedAdvertisements.add(visibleAdvertisements.get(randomIndex)); } } - List result = selectedIds.stream() - .map(advertisementRepository::getById) - .toList(); - - return AdvertisementsResponse.from(result); + return AdvertisementsResponse.from(selectedAdvertisements); } @Transactional From 6ce7b4dbe361ac6415d3ef12c53de51553e95712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:16:57 +0900 Subject: [PATCH 31/55] =?UTF-8?q?fix:=20SSE=20=EA=B5=AC=EB=8F=85=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20ErrorResp?= =?UTF-8?q?onse=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=B6=A9=EB=8F=8C=20(#452?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: SSE 구독 인증 실패 시 ErrorResponse 직렬화 충돌 * fix: SSE 인증 실패를 상태 코드와 전용 경고 로그로 분리 --- .../auth/web/LoginCheckInterceptor.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java b/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java index 00ef5313..46b0fb7a 100644 --- a/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java +++ b/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java @@ -2,8 +2,11 @@ import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.MediaType; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.HandlerInterceptor; @@ -13,6 +16,7 @@ import gg.agit.konect.global.exception.CustomException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; /** * 로그인 체크 인터셉터. @@ -21,6 +25,7 @@ * * 예외 발생 시 HandlerExceptionResolver를 통해 GlobalExceptionHandler로 위임합니다. */ +@Slf4j @Component public class LoginCheckInterceptor implements HandlerInterceptor { @@ -62,12 +67,54 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons request.setAttribute(AUTHENTICATED_USER_ID_ATTRIBUTE, userId); return true; } catch (CustomException e) { + if (isSseRequest(request, handlerMethod)) { + log.warn( + "SSE authentication failed: method={} uri={} status={} code={}", + request.getMethod(), + request.getRequestURI(), + e.getErrorCode().getHttpStatus().value(), + e.getErrorCode().getCode() + ); + response.setStatus(e.getErrorCode().getHttpStatus().value()); + return false; + } + // GlobalExceptionHandler가 처리하도록 위임 handlerExceptionResolver.resolveException(request, response, handler, e); return false; } } + private boolean isSseRequest(HttpServletRequest request, HandlerMethod handlerMethod) { + String accept = request.getHeader("Accept"); + if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { + return true; + } + + GetMapping getMapping = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getMethod(), GetMapping.class); + if (getMapping != null) { + for (String producedType : getMapping.produces()) { + if (MediaType.TEXT_EVENT_STREAM_VALUE.equals(producedType)) { + return true; + } + } + } + + RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation( + handlerMethod.getMethod(), RequestMapping.class); + if (requestMapping == null) { + return false; + } + + for (String producedType : requestMapping.produces()) { + if (MediaType.TEXT_EVENT_STREAM_VALUE.equals(producedType)) { + return true; + } + } + + return false; + } + private boolean isPublicEndpoint(HandlerMethod handlerMethod) { return AnnotatedElementUtils.findMergedAnnotation( handlerMethod.getMethod(), PublicApi.class) != null From bc92d72a6dfe189ecf9c54d96a25de1c72f17525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:31:02 +0900 Subject: [PATCH 32/55] =?UTF-8?q?fix:=20SSE=20=ED=99=98=EA=B2=BD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=BB=A4=EB=84=A5=EC=85=98=20=EC=A0=90=EC=9C=A0=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20open-in-view?= =?UTF-8?q?=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20(#453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-db.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index 4bc347d1..d1f42a2e 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -19,6 +19,7 @@ spring: time_zone: Asia/Seoul hibernate: ddl-auto: validate + open-in-view: false data: redis: From bf815388cb22bf03d03ed5e246f431212ea3506a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 29 Mar 2026 02:24:36 +0900 Subject: [PATCH 33/55] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-db.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index d1f42a2e..404202a1 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -17,6 +17,9 @@ spring: format_sql: true jdbc: time_zone: Asia/Seoul + batch_size: 100 + order_inserts: true + order_updates: true hibernate: ddl-auto: validate open-in-view: false From 35c1c5b108b8fecf50227dbe0100b89d42fbc345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:39:59 +0900 Subject: [PATCH 34/55] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=C2=B7=EC=95=8C=EB=A6=BC=20=EC=9D=B8=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V62__add_performance_indexes.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/resources/db/migration/V62__add_performance_indexes.sql diff --git a/src/main/resources/db/migration/V62__add_performance_indexes.sql b/src/main/resources/db/migration/V62__add_performance_indexes.sql new file mode 100644 index 00000000..45280026 --- /dev/null +++ b/src/main/resources/db/migration/V62__add_performance_indexes.sql @@ -0,0 +1,8 @@ +-- 성능 최적화 인덱스 추가 +-- 목적: 자주 사용되는 조회 조건에 대한 인덱스 추가 + +-- chat_message 테이블: 채팅방별 시간순 조회 (findByChatRoomId ORDER BY created_at) +CREATE INDEX idx_chat_message_room_created_at ON chat_message (chat_room_id, created_at DESC); + +-- notification_inbox 테이블: 읽지 않은 알림 카운트 (countByUserIdAndIsReadFalse) +CREATE INDEX idx_notification_inbox_user_read ON notification_inbox (user_id, is_read); From 304b723091394aa6dba5e51c6c7bad62a817c122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:19:25 +0900 Subject: [PATCH 35/55] =?UTF-8?q?ci:=20=EB=B9=8C=EB=93=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B0=9C=EC=84=A0=20(#461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 캐시 버전 업그레이드 및 캐시 키 통일 * feat: 오픈 텔레메트리 에이전트 캐싱 추가 * refactor: 오픈 텔레메트리 에이전트 다운로드를 COPY로 대체 * chore: 캐싱 버전 업데이트 * chore: Gradle 빌드 명령을 bootJar로 변경 * chore: OTEL 에이전트 버전을 변수로 추출 및 참조 * chore: GitHub Actions 의존성 해시로 고정 및 체크섬 검증 추가 * chore: OTEL 캐시 키에 OS 및 해시 정보 추가 --- .github/workflows/checkstyle.yml | 160 +++++++++--------- .github/workflows/deploy-monitoring.yml | 6 +- .github/workflows/deploy-prod.yml | 39 +++-- .github/workflows/deploy-stage.yml | 39 +++-- .../workflows/flyway-version-validator.yml | 2 +- Dockerfile | 3 +- 6 files changed, 143 insertions(+), 106 deletions(-) diff --git a/.github/workflows/checkstyle.yml b/.github/workflows/checkstyle.yml index 804f4363..625f1f02 100644 --- a/.github/workflows/checkstyle.yml +++ b/.github/workflows/checkstyle.yml @@ -1,80 +1,80 @@ -name: Checkstyle - -on: - pull_request: - branches: - - main - - develop - push: - branches: - - main - - develop - -permissions: - contents: read - pull-requests: write - -jobs: - checkstyle: - name: Code Style Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - submodules: recursive - token: ${{ secrets.SUBMODULE_TOKEN }} - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Run Checkstyle - run: ./gradlew checkstyleMain --no-daemon - - - name: Upload Checkstyle Report - if: failure() - uses: actions/upload-artifact@v4 - with: - name: checkstyle-report - path: | - build/reports/checkstyle/main.html - build/reports/checkstyle/main.xml - retention-days: 7 - - - name: Comment PR with Checkstyle Results - if: github.event_name == 'pull_request' && failure() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const path = require('path'); - - let comment = '## ⚠️ Checkstyle 위반 사항 발견\n\n'; - comment += 'Checkstyle 검사에서 코딩 컨벤션 위반이 발견되었습니다.\n\n'; - comment += '### 📋 상세 리포트\n'; - comment += '- [Main 소스 리포트 다운로드](../actions/runs/${{ github.run_id }})\n'; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); +name: Checkstyle + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + +permissions: + contents: read + pull-requests: write + +jobs: + checkstyle: + name: Code Style Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Checkstyle + run: ./gradlew checkstyleMain --no-daemon + + - name: Upload Checkstyle Report + if: failure() + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 + with: + name: checkstyle-report + path: | + build/reports/checkstyle/main.html + build/reports/checkstyle/main.xml + retention-days: 7 + + - name: Comment PR with Checkstyle Results + if: github.event_name == 'pull_request' && failure() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + let comment = '## ⚠️ Checkstyle 위반 사항 발견\n\n'; + comment += 'Checkstyle 검사에서 코딩 컨벤션 위반이 발견되었습니다.\n\n'; + comment += '### 📋 상세 리포트\n'; + comment += '- [Main 소스 리포트 다운로드](../actions/runs/${{ github.run_id }})\n'; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml index ceda976c..d42b99df 100644 --- a/.github/workflows/deploy-monitoring.yml +++ b/.github/workflows/deploy-monitoring.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Transfer monitoring configs to server - uses: appleboy/scp-action@v0.1.7 + uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7 with: host: ${{ secrets.SERVER_IP }} username: ${{ secrets.SERVER_USER }} @@ -27,7 +27,7 @@ jobs: rm: false - name: Deploy monitoring stack - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 env: WORK_DIR: ${{ secrets.PROD_WORK_DIR }} MONITORING_ENV: ${{ secrets.MONITORING_ENV }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 3f9088bd..b0b53b99 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -11,16 +11,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: '21' distribution: 'temurin' - name: Cache Gradle packages - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.gradle/caches @@ -29,6 +29,23 @@ jobs: restore-keys: | gradle-${{ runner.os }}- + - name: Cache OpenTelemetry Java Agent + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/otel-java-agent + key: otel-agent-${{ runner.os }}-${{ vars.OTEL_JAVA_AGENT_VERSION }}-${{ vars.OTEL_JAVA_AGENT_SHA256 }} + + - name: Prepare OpenTelemetry Agent + run: | + mkdir -p ~/.cache/otel-java-agent + if [ ! -f ~/.cache/otel-java-agent/opentelemetry-javaagent.jar ]; then + wget -O ~/.cache/otel-java-agent/opentelemetry-javaagent.jar \ + "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" + fi + # Verify checksum + echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} ~/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - + cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . + - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -38,20 +55,20 @@ jobs: source .env.example set +a unset JAVA_TOOL_OPTIONS - ./gradlew clean build -x test -Dspring.profiles.active=prod + ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=prod - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: soundbar91/konect-prod tags: | @@ -59,7 +76,7 @@ jobs: type=sha,prefix=sha- - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . platforms: linux/amd64,linux/arm64 @@ -68,9 +85,11 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup prod MySQL before deploy - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 with: host: ${{ secrets.PROD_SERVER_IP }} username: ${{ secrets.PROD_SERVER_USER }} @@ -97,7 +116,7 @@ jobs: find "$BACKUP_DIR" -type f -name '*.sql' -mtime +30 -delete - name: Deploy to prod server - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 with: host: ${{ secrets.PROD_SERVER_IP }} username: ${{ secrets.PROD_SERVER_USER }} diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 44e43bc2..d5b469f5 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -11,16 +11,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: '21' distribution: 'temurin' - name: Cache Gradle packages - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.gradle/caches @@ -29,6 +29,23 @@ jobs: restore-keys: | gradle-${{ runner.os }}- + - name: Cache OpenTelemetry Java Agent + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/otel-java-agent + key: otel-agent-${{ runner.os }}-${{ vars.OTEL_JAVA_AGENT_VERSION }}-${{ vars.OTEL_JAVA_AGENT_SHA256 }} + + - name: Prepare OpenTelemetry Agent + run: | + mkdir -p ~/.cache/otel-java-agent + if [ ! -f ~/.cache/otel-java-agent/opentelemetry-javaagent.jar ]; then + wget -O ~/.cache/otel-java-agent/opentelemetry-javaagent.jar \ + "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" + fi + # Verify checksum + echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} ~/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - + cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . + - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -38,20 +55,20 @@ jobs: source .env.example set +a unset JAVA_TOOL_OPTIONS - ./gradlew clean build -x test -Dspring.profiles.active=stage + ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=stage - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: soundbar91/konect-stage tags: | @@ -59,7 +76,7 @@ jobs: type=sha,prefix=sha- - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . platforms: linux/amd64,linux/arm64 @@ -68,9 +85,11 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup stage MySQL before deploy - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 with: host: ${{ secrets.STAGE_SERVER_IP }} username: ${{ secrets.STAGE_SERVER_USER }} @@ -97,7 +116,7 @@ jobs: find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete - name: Deploy to stage server - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 with: host: ${{ secrets.STAGE_SERVER_IP }} username: ${{ secrets.STAGE_SERVER_USER }} diff --git a/.github/workflows/flyway-version-validator.yml b/.github/workflows/flyway-version-validator.yml index 6578dba5..913b7db1 100644 --- a/.github/workflows/flyway-version-validator.yml +++ b/.github/workflows/flyway-version-validator.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Fetch all branches run: git fetch --all diff --git a/Dockerfile b/Dockerfile index 11cab8f3..5da5f12f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,7 @@ WORKDIR /app RUN addgroup -S konect && adduser -S konect -G konect COPY build/libs/KONECT_API.jar KONECT_API.jar - -RUN wget -O opentelemetry-javaagent.jar "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_JAVA_AGENT_VERSION}/opentelemetry-javaagent.jar" +COPY opentelemetry-javaagent.jar opentelemetry-javaagent.jar RUN chown -R konect:konect /app From 9daf25da1f8700019378ec51f90f67353c768b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 30 Mar 2026 22:32:43 +0900 Subject: [PATCH 36/55] =?UTF-8?q?fix:=20GitHub=20Actions=EC=97=90=EC=84=9C?= =?UTF-8?q?=20$HOME=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-prod.yml | 2 +- .github/workflows/deploy-stage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index b0b53b99..dbcebf99 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -43,7 +43,7 @@ jobs: "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" fi # Verify checksum - echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} ~/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - + echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} $HOME/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . - name: Grant execute permission for gradlew diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index d5b469f5..3b2f0405 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -43,7 +43,7 @@ jobs: "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" fi # Verify checksum - echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} ~/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - + echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} $HOME/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . - name: Grant execute permission for gradlew From 3f096964fb2eaf899175d4f0e53fc8f8167b4aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:37:38 +0900 Subject: [PATCH 37/55] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅방 멤버에 커스텀 방 이름 필드 추가 * feat: 사용자별 채팅방 이름 수정 기능 추가 * test: 채팅방 이름 수정 관련 통합 테스트 추가 * chore: 코드 포맷팅 * chore: 채팅방 API 문서 불필요한 오류 코드 제거 --- .../domain/chat/controller/ChatApi.java | 25 +++++- .../chat/controller/ChatController.java | 11 +++ .../chat/dto/ChatRoomNameUpdateRequest.java | 17 ++++ .../domain/chat/model/ChatRoomMember.java | 11 ++- .../domain/chat/service/ChatService.java | 51 +++++++++++- ...d_custom_room_name_to_chat_room_member.sql | 2 + .../integration/domain/chat/ChatApiTest.java | 83 +++++++++++++++++++ 7 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomNameUpdateRequest.java create mode 100644 src/main/resources/db/migration/V63__add_custom_room_name_to_chat_room_member.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 6b1b5a6f..04c3a6ce 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -13,6 +14,7 @@ import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.global.auth.annotation.UserId; @@ -47,10 +49,10 @@ ResponseEntity createOrGetChatRoom( @Operation(summary = "어드민과의 채팅방을 생성하거나 기존 채팅방을 반환한다.", description = """ ## 설명 - 문의하기 버튼에서 즉시 어드민과의 1:1 채팅으로 이동할 때 사용합니다. - + ## 로직 - 시스템의 기준 어드민 계정을 찾아 해당 계정과의 채팅방을 생성하거나 기존 채팅방을 반환합니다. - + ## 에러 - NOT_FOUND_USER (404): 어드민 계정을 찾을 수 없습니다. - CANNOT_CREATE_CHAT_ROOM_WITH_SELF (400): 자기 자신과는 채팅방을 만들 수 없습니다. @@ -85,7 +87,7 @@ ResponseEntity getChatRooms( - 채팅방 참여자만 메시지를 조회할 수 있습니다. - 일반 유저는 자신이 참여한 채팅방만 조회할 수 있습니다. - 어드민은 모든 어드민 채팅방을 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. """) @@ -126,4 +128,21 @@ ResponseEntity toggleChatMute( @PathVariable(value = "chatRoomId") Integer chatRoomId, @UserId Integer userId ); + + @Operation(summary = "내가 보는 채팅방 이름을 수정한다.", description = """ + ## 설명 + - 현재 사용자 기준으로만 보이는 채팅방 이름을 수정합니다. + - 다른 참여자에게는 영향을 주지 않습니다. + - null 또는 공백으로 보내면 기본 이름으로 되돌립니다. + + ## 에러 + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + """) + @PatchMapping("/rooms/{chatRoomId}/name") + ResponseEntity updateChatRoomName( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomNameUpdateRequest request, + @UserId Integer userId + ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index ef919c57..3a42bb01 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -13,6 +13,7 @@ import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.service.ChatService; @@ -81,4 +82,14 @@ public ResponseEntity toggleChatMute( ) { return ResponseEntity.ok(chatService.toggleMute(userId, chatRoomId)); } + + @Override + public ResponseEntity updateChatRoomName( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomNameUpdateRequest request, + @UserId Integer userId + ) { + chatService.updateChatRoomName(userId, chatRoomId, request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomNameUpdateRequest.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomNameUpdateRequest.java new file mode 100644 index 00000000..8e86ff78 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomNameUpdateRequest.java @@ -0,0 +1,17 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +public record ChatRoomNameUpdateRequest( + @Size(max = 30, message = "채팅방 이름은 30자 이내로 입력해주세요.") + @Schema( + description = "개인별 채팅방 이름. null 또는 공백이면 기본 이름으로 되돌립니다.", + example = "알바 이야기방", + requiredMode = NOT_REQUIRED + ) + String roomName +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index de7706f2..3ef6a9f0 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -40,17 +40,22 @@ public class ChatRoomMember extends BaseEntity { @Column(name = "last_read_at", nullable = false) private LocalDateTime lastReadAt; + @Column(name = "custom_room_name", length = 30) + private String customRoomName; + @Builder private ChatRoomMember( ChatRoomMemberId id, ChatRoom chatRoom, User user, - LocalDateTime lastReadAt + LocalDateTime lastReadAt, + String customRoomName ) { this.id = id; this.chatRoom = chatRoom; this.user = user; this.lastReadAt = lastReadAt; + this.customRoomName = customRoomName; } public static ChatRoomMember of(ChatRoom chatRoom, User user, LocalDateTime lastReadAt) { @@ -79,4 +84,8 @@ public void updateLastReadAt(LocalDateTime lastReadAt) { this.lastReadAt = lastReadAt; } } + + public void updateCustomRoomName(String customRoomName) { + this.customRoomName = customRoomName; + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index da1b7cbc..17e46ffb 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -20,11 +20,13 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; @@ -133,12 +135,13 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); List rooms = new ArrayList<>(); directRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( room.roomId(), room.chatType(), - room.roomName(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), room.roomImageUrl(), room.lastMessage(), room.lastSentAt(), @@ -149,7 +152,7 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { clubRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( room.roomId(), room.chatType(), - room.roomName(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), room.roomImageUrl(), room.lastMessage(), room.lastSentAt(), @@ -224,6 +227,15 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { return new ChatMuteResponse(isMuted); } + @Transactional + public void updateChatRoomName(Integer userId, Integer roomId, ChatRoomNameUpdateRequest request) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + ChatRoomMember roomMember = getAccessibleRoomMember(room, userId); + roomMember.updateCustomRoomName(normalizeCustomRoomName(request.roomName())); + } + private List getDirectChatRooms(Integer userId) { User user = userRepository.getById(userId); @@ -245,7 +257,7 @@ private List getDirectChatRooms(Integer userId) { Map userMap = allUserIds.isEmpty() ? Map.of() : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); + .collect(Collectors.toMap(User::getId, u -> u)); for (ChatRoom chatRoom : personalChatRooms) { List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); @@ -535,6 +547,20 @@ private Map getMuteMap(List roomIds, Integer userId) return muteMap; } + private Map getCustomRoomNameMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + return chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId).stream() + .filter(member -> StringUtils.hasText(member.getCustomRoomName())) + .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, ChatRoomMember::getCustomRoomName)); + } + + private String resolveRoomName(Integer roomId, String defaultRoomName, Map customRoomNameMap) { + return customRoomNameMap.getOrDefault(roomId, defaultRoomName); + } + private ChatRoom getDirectRoom(Integer roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); @@ -685,6 +711,17 @@ private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); } + private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { + if (room.isGroupRoom()) { + ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + ensureRoomMember(room, member.getUser(), member.getCreatedAt()); + return getRoomMember(room.getId(), userId); + } + + User user = userRepository.getById(userId); + return getOrCreateDirectRoomMember(room, user); + } + private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { @@ -695,6 +732,14 @@ private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); } + private String normalizeCustomRoomName(String roomName) { + if (!StringUtils.hasText(roomName)) { + return null; + } + + return roomName.trim(); + } + private void updateMemberLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); if (updated == 0) { diff --git a/src/main/resources/db/migration/V63__add_custom_room_name_to_chat_room_member.sql b/src/main/resources/db/migration/V63__add_custom_room_name_to_chat_room_member.sql new file mode 100644 index 00000000..d396cf15 --- /dev/null +++ b/src/main/resources/db/migration/V63__add_custom_room_name_to_chat_room_member.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_room_member + ADD COLUMN custom_room_name VARCHAR(30) NULL AFTER last_read_at; diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index d5037be7..aa3a8cf3 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -17,6 +17,7 @@ import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; @@ -237,6 +238,88 @@ void sendTooLongMessageFails() throws Exception { } } + @Nested + @DisplayName("PATCH /chats/rooms/{chatRoomId}/name - 채팅방 이름 수정") + class UpdateChatRoomName { + + @BeforeEach + void setUpRoomNameFixture() { + targetUser = createUser("상대유저", "2021136002"); + outsiderUser = createUser("외부유저", "2021136003"); + clearPersistenceContext(); + } + + @Test + @DisplayName("내가 수정한 채팅방 이름은 내 목록에만 반영된다") + void updateChatRoomNameOnlyForCurrentUser() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("알바 이야기방")) + .andExpect(status().isOk()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId())) + .isPresent() + .get() + .extracting(ChatRoomMember::getCustomRoomName) + .isEqualTo("알바 이야기방"); + + // when & then + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomName").value("알바 이야기방")); + + mockLoginUser(targetUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomName").value(normalUser.getName())); + } + + @Test + @DisplayName("공백 이름으로 요청하면 기본 채팅방 이름으로 되돌린다") + void blankRoomNameResetsToDefault() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("알바 이야기방")) + .andExpect(status().isOk()); + + // when + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest(" ")) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId())) + .isPresent() + .get() + .extracting(ChatRoomMember::getCustomRoomName) + .isNull(); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomName").value(targetUser.getName())); + } + + @Test + @DisplayName("참여하지 않은 사용자는 채팅방 이름을 수정할 수 없다") + void updateChatRoomNameForbidden() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(outsiderUser.getId()); + + // when & then + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("몰래 바꾸기")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + @Nested @DisplayName("GET /chats/rooms/{chatRoomId} - 채팅방 메시지 조회 실패") class GetMessagesFail { From 03e5d7cb5655d8a975296b474a815b4e7b628f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:27:52 +0900 Subject: [PATCH 38/55] =?UTF-8?q?feat:=201=EB=8C=801=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=82=98=EA=B0=80=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅방 타입 및 탈퇴 컬럼 추가 * feat: 1:1 채팅방 탈퇴 및 메시지 가시성 로직 추가 * test: 1:1 및 그룹 채팅방 탈퇴 테스트 케이스 추가 * chore: 코드 포맷팅 * chore: 불필요한 인덱스 생성 쿼리 제거 * refactor: LocalDateTime import로 코드 가독성 개선 * chore: 채팅방 복구 로직에 주석 추가 * refactor: 나간 채팅방 복구 로직 분리 및 구조 개선 * chore: 채팅방 복구 주석 표현 개선 --- .../domain/chat/controller/ChatApi.java | 23 +++ .../chat/controller/ChatController.java | 9 + .../konect/domain/chat/model/ChatRoom.java | 29 ++- .../domain/chat/model/ChatRoomMember.java | 54 ++++++ .../repository/ChatMessageRepository.java | 25 ++- .../chat/repository/ChatRoomRepository.java | 33 ++-- .../domain/chat/service/ChatService.java | 183 ++++++++++++++---- .../domain/user/service/UserService.java | 7 +- .../konect/global/code/ApiResponseCode.java | 1 + ...__add_chat_room_type_and_leave_columns.sql | 18 ++ .../integration/domain/chat/ChatApiTest.java | 165 +++++++++++++++- 11 files changed, 477 insertions(+), 70 deletions(-) create mode 100644 src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 04c3a6ce..3f906215 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.chat.controller; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -145,4 +146,26 @@ ResponseEntity updateChatRoomName( @Valid @RequestBody ChatRoomNameUpdateRequest request, @UserId Integer userId ); + + @Operation(summary = "채팅방에서 나간다.", description = """ + ## 설명 + - 동아리 채팅방은 나갈 수 없습니다. + - 1:1 채팅방은 소프트 딜리트 방식으로 나갑니다. + - 향후 일반 그룹 채팅방은 멤버십 제거 방식으로 나갈 수 있도록 설계합니다. + + ## 로직 + - 1:1 채팅방에서 나간 사용자는 기존 메시지를 숨기고 채팅방 목록에서도 제거됩니다. + - 상대방이 이후 새 메시지를 보내면 나간 사용자는 새 대화처럼 그 메시지부터 다시 보게 됩니다. + - 사용자가 다시 1:1 채팅을 열면 이전 대화가 아니라 새로 시작한 것처럼 보입니다. + + ## 에러 + - CANNOT_LEAVE_GROUP_CHAT_ROOM (400): 동아리 채팅방은 나갈 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + """) + @DeleteMapping("/rooms/{chatRoomId}") + ResponseEntity leaveChatRoom( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @UserId Integer userId + ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index 3a42bb01..dc4a79b0 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -92,4 +92,13 @@ public ResponseEntity updateChatRoomName( chatService.updateChatRoomName(userId, chatRoomId, request); return ResponseEntity.ok().build(); } + + @Override + public ResponseEntity leaveChatRoom( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @UserId Integer userId + ) { + chatService.leaveChatRoom(userId, chatRoomId); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java index f437e57f..6e5a8faf 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java @@ -7,12 +7,15 @@ import java.time.LocalDateTime; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.global.model.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -39,26 +42,40 @@ public class ChatRoom extends BaseEntity { @Column(name = "last_message_sent_at") private LocalDateTime lastMessageSentAt; + @Enumerated(EnumType.STRING) + @Column(name = "room_type", nullable = false, length = 20) + private ChatType roomType; + @ManyToOne(fetch = LAZY) @JoinColumn(name = "club_id") private Club club; @Builder - private ChatRoom(Integer id, Club club) { + private ChatRoom(Integer id, ChatType roomType, Club club) { this.id = id; + this.roomType = roomType; this.club = club; } public static ChatRoom directOf() { - return ChatRoom.builder().build(); + return ChatRoom.builder() + .roomType(ChatType.DIRECT) + .build(); } public static ChatRoom groupOf(Club club) { return ChatRoom.builder() + .roomType(ChatType.GROUP) .club(club) .build(); } + public static ChatRoom groupOf() { + return ChatRoom.builder() + .roomType(ChatType.GROUP) + .build(); + } + public static void validateIsNotSameParticipant(User sender, User receiver) { if (sender.getId().equals(receiver.getId())) { throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); @@ -66,11 +83,15 @@ public static void validateIsNotSameParticipant(User sender, User receiver) { } public boolean isDirectRoom() { - return club == null; + return roomType == ChatType.DIRECT; } public boolean isGroupRoom() { - return club != null; + return roomType == ChatType.GROUP; + } + + public boolean isClubGroupRoom() { + return roomType == ChatType.GROUP && club != null; } public void updateLastMessage(String lastMessageContent, LocalDateTime lastMessageSentAt) { diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index 3ef6a9f0..79dd6cde 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -40,6 +40,12 @@ public class ChatRoomMember extends BaseEntity { @Column(name = "last_read_at", nullable = false) private LocalDateTime lastReadAt; + @Column(name = "visible_message_from") + private LocalDateTime visibleMessageFrom; + + @Column(name = "left_at") + private LocalDateTime leftAt; + @Column(name = "custom_room_name", length = 30) private String customRoomName; @@ -49,12 +55,16 @@ private ChatRoomMember( ChatRoom chatRoom, User user, LocalDateTime lastReadAt, + LocalDateTime visibleMessageFrom, + LocalDateTime leftAt, String customRoomName ) { this.id = id; this.chatRoom = chatRoom; this.user = user; this.lastReadAt = lastReadAt; + this.visibleMessageFrom = visibleMessageFrom; + this.leftAt = leftAt; this.customRoomName = customRoomName; } @@ -88,4 +98,48 @@ public void updateLastReadAt(LocalDateTime lastReadAt) { public void updateCustomRoomName(String customRoomName) { this.customRoomName = customRoomName; } + + public boolean hasLeft() { + return leftAt != null; + } + + public void leaveDirectRoom(LocalDateTime leftAt) { + this.leftAt = leftAt; + this.visibleMessageFrom = leftAt; + updateLastReadAt(leftAt); + } + + /** + * 나간 이후 새 메시지가 생겨 다시 볼 수 있을 때 사용한다. + *

+ * 나간 상태만 해제하고, 기존 {@code visibleMessageFrom}은 유지한다. + * 그래서 나간 이후 도착한 메시지부터 계속 보인다. + */ + public void restoreDirectRoom() { + this.leftAt = null; + } + + /** + * 사용자가 채팅방을 다시 열어 새 대화를 시작할 때 사용한다. + *

+ * 나간 상태를 해제하고 {@code visibleMessageFrom}도 새로 갱신한다. + * 그래서 전달한 시점 이후 메시지부터 새 대화처럼 보인다. + */ + public void reopenDirectRoom(LocalDateTime visibleMessageFrom) { + this.leftAt = null; + this.visibleMessageFrom = visibleMessageFrom; + updateLastReadAt(visibleMessageFrom); + } + + public boolean hasVisibleMessages(ChatRoom room) { + if (room.getLastMessageSentAt() == null) { + return false; + } + + if (visibleMessageFrom == null) { + return true; + } + + return room.getLastMessageSentAt().isAfter(visibleMessageFrom); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index dc7876ac..e796654b 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.chat.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; @@ -39,9 +40,25 @@ List countUnreadMessagesByChatRoomIdsAndUserId( FROM ChatMessage cm JOIN FETCH cm.sender WHERE cm.chatRoom.id = :chatRoomId + AND (:visibleMessageFrom IS NULL OR cm.createdAt > :visibleMessageFrom) ORDER BY cm.createdAt DESC """) - Page findByChatRoomId(@Param("chatRoomId") Integer chatRoomId, Pageable pageable); + Page findByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom, + Pageable pageable + ); + + @Query(""" + SELECT COUNT(m) + FROM ChatMessage m + WHERE m.chatRoom.id = :chatRoomId + AND (:visibleMessageFrom IS NULL OR m.createdAt > :visibleMessageFrom) + """) + long countByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom + ); @Query(""" SELECT new gg.agit.konect.domain.chat.dto.UnreadMessageCount( @@ -102,10 +119,4 @@ SELECT MAX(m2.id) """) List findLatestMessagesByRoomIds(@Param("roomIds") List roomIds); - @Query(""" - SELECT COUNT(m) - FROM ChatMessage m - WHERE m.chatRoom.id = :chatRoomId - """) - long countByChatRoomId(@Param("chatRoomId") Integer chatRoomId); } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index 6cc9f7f0..bc96ac82 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -9,6 +9,7 @@ import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.user.enums.UserRole; public interface ChatRoomRepository extends Repository { @@ -21,10 +22,10 @@ public interface ChatRoomRepository extends Repository { JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id LEFT JOIN FETCH cr.club WHERE crm.id.userId = :userId - AND cr.club IS NULL + AND cr.roomType = :roomType ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findByUserId(@Param("userId") Integer userId); + List findByUserId(@Param("userId") Integer userId, @Param("roomType") ChatType roomType); @Query(""" SELECT cr @@ -39,13 +40,17 @@ public interface ChatRoomRepository extends Repository { FROM ChatRoom cr JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType GROUP BY cr HAVING COUNT(crm) = 2 AND SUM(CASE WHEN crm.id.userId = :userId1 THEN 1 ELSE 0 END) = 1 AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 """) - Optional findByTwoUsers(@Param("userId1") Integer userId1, @Param("userId2") Integer userId2); + Optional findByTwoUsers( + @Param("userId1") Integer userId1, + @Param("userId2") Integer userId2, + @Param("roomType") ChatType roomType + ); @Query(""" SELECT cr @@ -69,15 +74,16 @@ AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id LEFT JOIN FETCH cr.club WHERE crm.id.userId = :userId + AND cr.roomType = :roomType AND cr.club IS NOT NULL ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findGroupRoomsByUserId(@Param("userId") Integer userId); + List findGroupRoomsByUserId(@Param("userId") Integer userId, @Param("roomType") ChatType roomType); @Query(""" SELECT DISTINCT cr FROM ChatRoom cr - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND EXISTS ( SELECT 1 FROM ChatRoomMember adminMember JOIN adminMember.user adminUser @@ -92,12 +98,15 @@ AND EXISTS ( ) ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findAllAdminUserDirectRooms(@Param("adminRole") UserRole adminRole); + List findAllAdminUserDirectRooms( + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType + ); @Query(""" SELECT DISTINCT cr FROM ChatRoom cr - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND EXISTS ( SELECT 1 FROM ChatRoomMember systemAdminMember WHERE systemAdminMember.id.chatRoomId = cr.id @@ -113,7 +122,8 @@ AND EXISTS ( """) List findAllSystemAdminDirectRooms( @Param("systemAdminId") Integer systemAdminId, - @Param("adminRole") UserRole adminRole + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType ); /** @@ -145,7 +155,7 @@ List findAllSystemAdminDirectRooms( LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id AND cm.sender.id <> :systemAdminId AND cm.createdAt > adminCrm.lastReadAt - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND u.role != :adminRole AND EXISTS ( SELECT 1 FROM ChatMessage userReply @@ -158,6 +168,7 @@ AND EXISTS ( """) List findAdminChatRoomsOptimized( @Param("systemAdminId") Integer systemAdminId, - @Param("adminRole") UserRole adminRole + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 17e46ffb..da2c2732 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.chat.service; import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_LEAVE_GROUP_CHAT_ROOM; import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; @@ -86,18 +87,22 @@ public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreat return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); } - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(currentUser.getId(), targetUser.getId()) + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers( + currentUser.getId(), + targetUser.getId(), + ChatType.DIRECT + ) .orElseGet(() -> chatRoomRepository.save(ChatRoom.directOf())); LocalDateTime joinedAt = Objects.requireNonNull(chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null"); - ensureRoomMember(chatRoom, currentUser, joinedAt); + ensureDirectRoomRequester(chatRoom, currentUser, joinedAt); ensureRoomMember(chatRoom, targetUser, joinedAt); return ChatRoomResponse.from(chatRoom); } private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, User adminUser) { - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUser.getId()) + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUser.getId(), ChatType.DIRECT) .orElseGet(() -> { ChatRoom newRoom = chatRoomRepository.save(ChatRoom.directOf()); User systemAdmin = userRepository.getById(SYSTEM_ADMIN_ID); @@ -112,7 +117,7 @@ private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, LocalDateTime joinedAt = Objects.requireNonNull( chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" ); - ensureRoomMember(chatRoom, adminUser, joinedAt); + ensureDirectRoomRequester(chatRoom, adminUser, joinedAt); return ChatRoomResponse.from(chatRoom); } @@ -125,6 +130,24 @@ public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { return createOrGetChatRoom(currentUserId, new ChatRoomCreateRequest(adminUser.getId())); } + @Transactional + public void leaveChatRoom(Integer userId, Integer roomId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + if (room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_LEAVE_GROUP_CHAT_ROOM); + } + + ChatRoomMember member = getRoomMember(roomId, userId); + if (room.isDirectRoom()) { + member.leaveDirectRoom(LocalDateTime.now()); + return; + } + + chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, userId); + } + @Transactional public ChatRoomsSummaryResponse getChatRooms(Integer userId) { List directRooms = getDirectChatRooms(userId); @@ -202,7 +225,7 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); } else { - getOrCreateDirectRoomMember(room, user); + getAccessibleDirectRoomMember(room, user); } Boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( NotificationTargetType.CHAT_ROOM, @@ -244,24 +267,18 @@ private List getDirectChatRooms(Integer userId) { } List roomSummaries = new ArrayList<>(); - List personalChatRooms = chatRoomRepository.findByUserId(userId); - Map> roomMemberInfoMap = getRoomMemberInfoMap(personalChatRooms); + List personalChatRooms = chatRoomRepository.findByUserId(userId, ChatType.DIRECT); + Map> roomMembersMap = getRoomMembersMap(personalChatRooms); Map personalUnreadCountMap = getUnreadCountMap(extractChatRoomIds(personalChatRooms), userId); - List allUserIds = roomMemberInfoMap.values().stream() - .flatMap(List::stream) - .map(MemberInfo::userId) - .distinct() - .toList(); - - Map userMap = allUserIds.isEmpty() - ? Map.of() - : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); - for (ChatRoom chatRoom : personalChatRooms) { - List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); - User chatPartner = resolveDirectChatPartner(memberInfos, user.getId(), userMap); + List members = roomMembersMap.getOrDefault(chatRoom.getId(), List.of()); + ChatRoomMember currentMember = findRoomMember(members, userId); + if (currentMember == null || !isDirectRoomVisibleToUser(chatRoom, currentMember)) { + continue; + } + + User chatPartner = resolveDirectChatPartner(members, user.getId()); if (chatPartner == null) { continue; } @@ -271,8 +288,8 @@ private List getDirectChatRooms(Integer userId) { ChatType.DIRECT, chatPartner.getName(), chatPartner.getImageUrl(), - chatRoom.getLastMessageContent(), - chatRoom.getLastMessageSentAt(), + getVisibleLastMessageContent(chatRoom, currentMember), + getVisibleLastMessageSentAt(chatRoom, currentMember), personalUnreadCountMap.getOrDefault(chatRoom.getId(), 0), false )); @@ -290,7 +307,7 @@ private List getDirectChatRooms(Integer userId) { private List getAdminDirectChatRooms() { List projections = chatRoomRepository.findAdminChatRoomsOptimized( - SYSTEM_ADMIN_ID, UserRole.ADMIN + SYSTEM_ADMIN_ID, UserRole.ADMIN, ChatType.DIRECT ); return projections.stream() @@ -316,6 +333,7 @@ private ChatMessagePageResponse getDirectChatRoomMessages( ChatRoom chatRoom = getDirectRoom(roomId); User user = userRepository.getById(userId); ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); + LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(member, chatRoom); LocalDateTime readAt = LocalDateTime.now(); chatPresenceService.recordPresence(roomId, userId); @@ -323,7 +341,7 @@ private ChatMessagePageResponse getDirectChatRoomMessages( boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom); PageRequest pageable = PageRequest.of(page - 1, limit); - Page messages = chatMessageRepository.findByChatRoomId(roomId, pageable); + Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); List members = chatRoomMemberRepository.findByChatRoomId(roomId); if (isAdminViewingSystemRoom) { @@ -373,31 +391,26 @@ private ChatMessageDetailResponse sendDirectMessage( ) { ChatRoom chatRoom = getDirectRoom(roomId); User sender = userRepository.getById(userId); - getOrCreateDirectRoomMember(chatRoom, sender); - - List memberResults = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId)); - List memberInfos = memberResults.stream() - .map(row -> new MemberInfo((Integer)row[1], (LocalDateTime)row[2])) - .toList(); - - List memberUserIds = memberInfos.stream().map(MemberInfo::userId).toList(); - Map userMap = userRepository.findAllByIdIn(memberUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); - - User receiver = resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); + ChatRoomMember senderMember = getAccessibleDirectRoomMember(chatRoom, sender); + boolean senderHadLeft = senderMember.hasLeft(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + User receiver = resolveMessageReceiver(sender, members); ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, sender, request.content()) ); + if (senderHadLeft) { + senderMember.restoreDirectRoom(); + } chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); List sortedReadBaselines = toSortedReadBaselines(members); notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); - boolean isSystemAdminRoom = memberInfos.stream() - .anyMatch(info -> info.userId().equals(SYSTEM_ADMIN_ID)); + boolean isSystemAdminRoom = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); publishAdminChatEventIfNeeded(isSystemAdminRoom, sender, request.content()); return new ChatMessageDetailResponse( @@ -459,8 +472,8 @@ private ChatMessagePageResponse getClubMessagesByRoomId( updateLastReadAt(roomId, userId, LocalDateTime.now()); PageRequest pageable = PageRequest.of(page - 1, limit); - long totalCount = chatMessageRepository.countByChatRoomId(roomId); - Page messagePage = chatMessageRepository.findByChatRoomId(roomId, pageable); + long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); + Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); List messages = messagePage.getContent(); List members = chatRoomMemberRepository.findByChatRoomId(roomId); List sortedReadBaselines = toSortedReadBaselines(members); @@ -575,7 +588,7 @@ private ChatRoom getDirectRoom(Integer roomId) { private ChatRoom getClubRoom(Integer roomId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); - if (!room.isGroupRoom() || room.getClub() == null) { + if (!room.isClubGroupRoom()) { throw CustomException.of(ApiResponseCode.NOT_FOUND_GROUP_CHAT_ROOM); } return room; @@ -719,7 +732,7 @@ private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { } User user = userRepository.getById(userId); - return getOrCreateDirectRoomMember(room, user); + return getAccessibleDirectRoomMember(room, user); } private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { @@ -732,6 +745,21 @@ private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); } + private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime joinedAt) { + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .ifPresentOrElse(member -> { + if (member.hasLeft()) { + member.reopenDirectRoom(LocalDateTime.now()); + return; + } + + LocalDateTime lastReadAt = member.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(joinedAt)) { + member.updateLastReadAt(joinedAt); + } + }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); + } + private String normalizeCustomRoomName(String roomName) { if (!StringUtils.hasText(roomName)) { return null; @@ -862,6 +890,34 @@ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) }); } + private ChatRoomMember getAccessibleDirectRoomMember(ChatRoom chatRoom, User user) { + ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); + restoreDirectRoomIfVisible(member, chatRoom); + return member; + } + + private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom chatRoom) { + LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + restoreDirectRoomIfVisible(member, chatRoom); + return visibleMessageFrom; + } + + /** + * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, + * 새 메시지가 이미 존재하면 나간 상태를 해제한다. + */ + private void restoreDirectRoomIfVisible(ChatRoomMember member, ChatRoom chatRoom) { + if (!member.hasLeft()) { + return; + } + + if (!member.hasVisibleMessages(chatRoom)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + member.restoreDirectRoom(); + } + private boolean isSystemAdminRoom(ChatRoom chatRoom) { List memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds( List.of(chatRoom.getId()) @@ -891,6 +947,31 @@ private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId return message.getSender().getId(); } + private ChatRoomMember findRoomMember(List members, Integer userId) { + return members.stream() + .filter(member -> member.getUserId().equals(userId)) + .findFirst() + .orElse(null); + } + + private boolean isDirectRoomVisibleToUser(ChatRoom room, ChatRoomMember member) { + return !member.hasLeft() || member.hasVisibleMessages(room); + } + + private String getVisibleLastMessageContent(ChatRoom room, ChatRoomMember member) { + if (!member.hasVisibleMessages(room)) { + return null; + } + return room.getLastMessageContent(); + } + + private LocalDateTime getVisibleLastMessageSentAt(ChatRoom room, ChatRoomMember member) { + if (!member.hasVisibleMessages(room)) { + return null; + } + return room.getLastMessageSentAt(); + } + private Map> getRoomMembersMap(List rooms) { if (rooms.isEmpty()) { return Map.of(); @@ -931,6 +1012,22 @@ private User findDirectPartner(List members, Integer userId) { .orElse(null); } + private User resolveDirectChatPartner(List members, Integer userId) { + boolean hasSystemAdmin = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); + + if (hasSystemAdmin) { + return members.stream() + .map(ChatRoomMember::getUser) + .filter(memberUser -> memberUser.getId().equals(SYSTEM_ADMIN_ID)) + .findFirst() + .orElse(null); + } + + return findDirectPartner(members, userId); + } + private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index baa48c07..aa3edc2e 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; @@ -116,7 +117,11 @@ private void sendWelcomeMessage(User newUser) { return; } ChatRoom.validateIsNotSameParticipant(operator, newUser); - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(operator.getId(), newUser.getId()) + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers( + operator.getId(), + newUser.getId(), + ChatType.DIRECT + ) .orElseGet(() -> chatRoomRepository.save(ChatRoom.directOf())); LocalDateTime joinedAt = Objects.requireNonNull( chatRoom.getCreatedAt(), diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 1d5c7d2b..a39b6598 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -19,6 +19,7 @@ public enum ApiResponseCode { FAILED_EXTRACT_EMAIL(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 이메일 정보를 가져올 수 없습니다."), FAILED_EXTRACT_PROVIDER_ID(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 제공자 식별자를 가져올 수 없습니다."), CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), + CANNOT_LEAVE_GROUP_CHAT_ROOM(HttpStatus.BAD_REQUEST, "동아리 채팅방은 나갈 수 없습니다."), INVALID_CHAT_ROOM_CREATE_REQUEST(HttpStatus.BAD_REQUEST, "clubId 또는 targetUserId 중 하나만 전달해야 합니다."), CANNOT_CHANGE_OWN_POSITION(HttpStatus.BAD_REQUEST, "자기 자신의 직책은 변경할 수 없습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), diff --git a/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql new file mode 100644 index 00000000..0c62d38e --- /dev/null +++ b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql @@ -0,0 +1,18 @@ +ALTER TABLE chat_room + ADD COLUMN room_type VARCHAR(20) NULL AFTER last_message_sent_at; + +UPDATE chat_room +SET room_type = CASE + WHEN club_id IS NULL THEN 'DIRECT' + ELSE 'GROUP' +END; + +ALTER TABLE chat_room + MODIFY COLUMN room_type VARCHAR(20) NOT NULL; + +ALTER TABLE chat_room_member + ADD COLUMN visible_message_from TIMESTAMP NULL AFTER last_read_at, + ADD COLUMN left_at TIMESTAMP NULL AFTER visible_message_from; + +CREATE INDEX idx_chat_room_member_user_left_at + ON chat_room_member (user_id, left_at); diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index aa3a8cf3..ddbc120a 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -18,6 +18,7 @@ import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; @@ -25,12 +26,14 @@ import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; import gg.agit.konect.domain.notification.service.NotificationService; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.UniversityFixture; import gg.agit.konect.support.fixture.UserFixture; @@ -90,7 +93,8 @@ void createDirectChatRoomSuccess() throws Exception { .andExpect(jsonPath("$.chatRoomId").isNumber()); clearPersistenceContext(); - assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId())).isPresent(); + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId(), ChatType.DIRECT)) + .isPresent(); assertThat(countDirectRoomsBetween(normalUser, targetUser)).isEqualTo(beforeCount + 1); } @@ -132,7 +136,7 @@ void createDirectChatRoomReturnsExistingRoom() throws Exception { .andExpect(jsonPath("$.chatRoomId").value(existingRoom.getId())); clearPersistenceContext(); - assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId())) + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId(), ChatType.DIRECT)) .isPresent() .get() .extracting(ChatRoom::getId) @@ -202,7 +206,9 @@ void sendMessageSuccess() throws Exception { .andExpect(jsonPath("$.isMine").value(true)); clearPersistenceContext(); - assertThat(chatMessageRepository.findByChatRoomId(chatRoom.getId(), PageRequest.of(0, 20)).getContent()) + assertThat( + chatMessageRepository.findByChatRoomId(chatRoom.getId(), null, PageRequest.of(0, 20)).getContent() + ) .hasSize(1) .extracting(ChatMessage::getContent) .containsExactly("안녕하세요"); @@ -320,6 +326,157 @@ void updateChatRoomNameForbidden() throws Exception { } } + @Nested + @DisplayName("DELETE /chats/rooms/{chatRoomId} - 채팅방 나가기") + class LeaveChatRoom { + + @BeforeEach + void setUpLeaveFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("1:1 채팅방을 나가면 목록에서 숨겨지고 새 메시지부터 다시 보인다") + void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) + .andExpect(status().isOk()); + + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + ChatRoomMember leftMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(leftMember.hasLeft()).isTrue(); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms").isEmpty()); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + mockLoginUser(targetUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) + .andExpect(status().isOk()); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomId").value(chatRoom.getId())) + .andExpect(jsonPath("$.rooms[0].lastMessage").value("다시 안녕")) + .andExpect(jsonPath("$.rooms[0].unreadCount").value(1)); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.messages[0].content").value("다시 안녕")); + + mockLoginUser(targetUser.getId()); + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(2)) + .andExpect(jsonPath("$.messages[0].content").value("다시 안녕")) + .andExpect(jsonPath("$.messages[1].content").value("첫 메시지")); + } + + @Test + @DisplayName("나간 뒤 다시 채팅방을 열면 처음 대화하는 것처럼 빈 메시지 목록을 본다") + void createOrGetChatRoomAfterLeaveStartsFresh() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) + .andExpect(status().isOk()); + + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(chatRoom.getId())); + + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomId").value(chatRoom.getId())) + .andExpect(jsonPath("$.rooms[0].lastMessage").doesNotExist()); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(0)) + .andExpect(jsonPath("$.messages").isEmpty()); + } + + @Test + @DisplayName("나간 뒤 새 메시지가 오기 전에는 방 조작이 불가능하다") + void cannotOperateHiddenDirectRoomBeforeNewMessage() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("몰래 보내기")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("숨김방")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + performPost("/chats/rooms/" + chatRoom.getId() + "/mute") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("동아리 채팅방은 나갈 수 없다") + void leaveGroupChatRoomFails() throws Exception { + Club club = persist(ClubFixture.create(university)); + ChatRoom groupRoom = persist(ChatRoom.groupOf(club)); + ChatRoom managedGroupRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + persist(ChatRoomMember.of(managedGroupRoom, managedNormalUser, groupRoom.getCreatedAt())); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performDelete("/chats/rooms/" + groupRoom.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_LEAVE_GROUP_CHAT_ROOM")); + } + + @Test + @DisplayName("일반 그룹 채팅방은 멤버십 삭제 방식으로 나갈 수 있다") + void leaveOpenGroupChatRoomDeletesMembership() throws Exception { + ChatRoom openGroupRoom = persist(ChatRoom.groupOf()); + ChatRoom managedOpenGroupRoom = entityManager.getReference(ChatRoom.class, openGroupRoom.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + User managedTargetUser = entityManager.getReference(User.class, targetUser.getId()); + persist(ChatRoomMember.of(managedOpenGroupRoom, managedNormalUser, openGroupRoom.getCreatedAt())); + persist(ChatRoomMember.of(managedOpenGroupRoom, managedTargetUser, openGroupRoom.getCreatedAt())); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performDelete("/chats/rooms/" + openGroupRoom.getId()) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(openGroupRoom.getId(), normalUser.getId())) + .isEmpty(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(openGroupRoom.getId(), targetUser.getId())) + .isPresent(); + } + } + @Nested @DisplayName("GET /chats/rooms/{chatRoomId} - 채팅방 메시지 조회 실패") class GetMessagesFail { @@ -410,7 +567,7 @@ private User createUser(String name, String studentId) { } private long countDirectRoomsBetween(User firstUser, User secondUser) { - return chatRoomRepository.findByUserId(firstUser.getId()).stream() + return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() .map(ChatRoom::getId) .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) .count(); From 8c3db7ee5389c5b87cb660cd83402e036d77fc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:03:42 +0900 Subject: [PATCH 39/55] Merge pull request #465 from BCSDLab/refactor/CAM-278-docker-build-perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci: 도커 빌드 시간 개선 --- .dockerignore | 8 ++++++++ .github/workflows/deploy-monitoring.yml | 2 +- .github/workflows/deploy-prod.yml | 16 +++++++++++++--- .github/workflows/deploy-stage.yml | 16 +++++++++++++--- src/main/resources/application.yml | 3 +++ 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..47ad3113 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** + +!Dockerfile +!.dockerignore +!build/ +!build/libs/ +!build/libs/KONECT_API.jar +!opentelemetry-javaagent.jar diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml index d42b99df..77d0d595 100644 --- a/.github/workflows/deploy-monitoring.yml +++ b/.github/workflows/deploy-monitoring.yml @@ -27,7 +27,7 @@ jobs: rm: false - name: Deploy monitoring stack - uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 + uses: appleboy/ssh-action@v1.2.0 env: WORK_DIR: ${{ secrets.PROD_WORK_DIR }} MONITORING_ENV: ${{ secrets.MONITORING_ENV }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index dbcebf99..9f64a052 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -79,7 +79,7 @@ jobs: uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -89,7 +89,7 @@ jobs: OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup prod MySQL before deploy - uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.PROD_SERVER_IP }} username: ${{ secrets.PROD_SERVER_USER }} @@ -97,6 +97,7 @@ jobs: port: ${{ secrets.PROD_SERVER_PORT }} script: | set -euo pipefail + START_TIME=$(date +%s) WORK_DIR="${{ secrets.PROD_WORK_DIR }}" MYSQL_CONTAINER="mysql-prod" @@ -115,13 +116,22 @@ jobs: find "$BACKUP_DIR" -type f -name '*.sql' -mtime +30 -delete + END_TIME=$(date +%s) + echo "Prod MySQL backup completed in $((END_TIME - START_TIME))s" + - name: Deploy to prod server - uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.PROD_SERVER_IP }} username: ${{ secrets.PROD_SERVER_USER }} key: ${{ secrets.PROD_SERVER_SSH_KEY }} port: ${{ secrets.PROD_SERVER_PORT }} script: | + set -euo pipefail + START_TIME=$(date +%s) + cd ${{ secrets.PROD_WORK_DIR }} ./deploy.sh + + END_TIME=$(date +%s) + echo "Prod deploy.sh completed in $((END_TIME - START_TIME))s" diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 3b2f0405..d5c8630a 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -79,7 +79,7 @@ jobs: uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -89,7 +89,7 @@ jobs: OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup stage MySQL before deploy - uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.STAGE_SERVER_IP }} username: ${{ secrets.STAGE_SERVER_USER }} @@ -97,6 +97,7 @@ jobs: port: ${{ secrets.STAGE_SERVER_PORT }} script: | set -euo pipefail + START_TIME=$(date +%s) WORK_DIR="${{ secrets.STAGE_WORK_DIR }}" MYSQL_CONTAINER="mysql-stage" @@ -115,13 +116,22 @@ jobs: find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete + END_TIME=$(date +%s) + echo "Stage MySQL backup completed in $((END_TIME - START_TIME))s" + - name: Deploy to stage server - uses: appleboy/ssh-action@b60142998894e495c513803efc6d5d72a72c968a # v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.STAGE_SERVER_IP }} username: ${{ secrets.STAGE_SERVER_USER }} key: ${{ secrets.STAGE_SERVER_SSH_KEY }} port: ${{ secrets.STAGE_SERVER_PORT }} script: | + set -euo pipefail + START_TIME=$(date +%s) + cd ${{ secrets.STAGE_WORK_DIR }} ./deploy.sh + + END_TIME=$(date +%s) + echo "Stage deploy.sh completed in $((END_TIME - START_TIME))s" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 415cce7e..c81f1d98 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,8 @@ spring: application: name: konect-backend + lifecycle: + timeout-per-shutdown-phase: 30s config: import: - classpath:application-db.yml @@ -44,6 +46,7 @@ cors: allowedOrigins: ${ALLOWED_ORIGINS} server: + shutdown: graceful forward-headers-strategy: framework tomcat: From 25a2df6b54b0b1edaf4f914414d10abec84bb3f2 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:35:59 +0900 Subject: [PATCH 40/55] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=BB=A4=EB=84=A5=EC=85=98=20=ED=92=80=20=EA=B3=A0=EA=B0=88=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: HikariCP 커넥션 풀 설정 추가 * feat: 알림 전용 스레드풀 설정 추가 * refactor: NotificationService 알림 전용 스레드풀 적용 * refactor: ChatRoomMembershipService 멤버 초기화 및 읽음 처리 로직 분리 * refactor: ChatService 채팅 조회 메서드 읽기 전용 트랜잭션 분리 * fix: ChatRoomMembershipService 캐스팅 후 공백 제거 * refactor: AsyncConfig 기본 executor 지정 및 알림 스레드풀 포화 시 드롭 정책 적용 * fix: ChatRoomMembershipService 채팅방 및 멤버 동시 생성 시 중복 방지 처리 * refactor: ChatService 조회 메서드 비트랜잭션 오케스트레이터로 전환 및 presence 기록 순서 변경 * fix: updateLastReadAtIfOlder 쿼리에 NULL 값 갱신 조건 추가 * fix: ChatRoomMembershipService 클럽 방 null 방어 및 isSystemAdminRoom 중복 호출 제거 * fix: AsyncConfig 기본 executor CallerRunsPolicy 적용 및 알림 거절 시 예외 전파 * fix: ChatRoomMembershipService 클럽 방 생성 race condition 및 ADMIN 멤버 readAt 시각 정합성 수정 * feat: Slack 전용 스레드풀 추가 및 미지정 @Async에 명시적 executor 지정 * fix: DataIntegrityViolationException 처리 시 중복 키 예외만 선별하여 처리 * fix: ChatService Redis presence 기록 실패 시 메시지 조회 중단 방지 * fix: isDuplicateKeyException DB 비종속적 중복 키 감지 패턴 추가 * fix: slackTaskExecutor CallerRunsPolicy를 로깅 후 RejectedExecutionException으로 변경 * fix: ChatService recordPresenceSafely 메서드 구문 오류 수정 * fix: ChatRoomMembershipService deadlock 방지 정렬 및 DuplicateKeyException 선별 처리 * fix: ChatService getDirectChatRoomMessages readAt 재선언 및 resolveMessageReceiver 누락 수정 * fix: ChatService recordPresenceSafely 무한 재귀 수정 및 중복 호출 제거 --- .../repository/ChatRoomMemberRepository.java | 4 +- .../service/ChatRoomMembershipService.java | 214 ++++++++++++++++- .../domain/chat/service/ChatService.java | 219 +++++------------- .../service/NotificationService.java | 10 +- .../konect/global/config/AsyncConfig.java | 79 ++++++- .../slack/ai/SlackAIService.java | 2 +- .../slack/listener/ChatSlackListener.java | 2 +- .../slack/listener/InquirySlackListener.java | 2 +- .../slack/listener/UserSlackListener.java | 4 +- src/main/resources/application-db.yml | 5 + 10 files changed, 363 insertions(+), 178 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 25503085..9707f1a4 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -79,13 +79,13 @@ List findByChatRoomIdsAndUserId( @Param("userId") Integer userId ); - @Modifying + @Modifying(clearAutomatically = true) @Query(""" UPDATE ChatRoomMember crm SET crm.lastReadAt = :lastReadAt WHERE crm.id.chatRoomId = :chatRoomId AND crm.id.userId = :userId - AND crm.lastReadAt < :lastReadAt + AND (crm.lastReadAt IS NULL OR crm.lastReadAt < :lastReadAt) """) int updateLastReadAtIfOlder( @Param("chatRoomId") Integer chatRoomId, diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 5e3d6bc3..fb3f3185 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -1,32 +1,53 @@ package gg.agit.konect.domain.chat.service; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; + import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ChatRoomMembershipService { + private static final int SYSTEM_ADMIN_ID = 1; + private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ClubMemberRepository clubMemberRepository; + private final UserRepository userRepository; @Transactional public void addClubMember(ClubMember clubMember) { LocalDateTime baseline = Objects.requireNonNull(clubMember.getCreatedAt()); - ChatRoom room = chatRoomRepository.findByClubId(clubMember.getClub().getId()) - .orElseGet(() -> chatRoomRepository.save(ChatRoom.groupOf(clubMember.getClub()))); + ChatRoom room = findOrCreateClubRoom(clubMember.getClub()); ensureMember(room, clubMember.getUser(), baseline); } @@ -37,6 +58,147 @@ public void addDirectMembers(ChatRoom room, User firstUser, User secondUser, Loc ensureMember(room, secondUser, baseline); } + @Transactional + public void removeClubMember(Integer clubId, Integer userId) { + chatRoomRepository.findByClubId(clubId) + .ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId)); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void ensureClubRoomMemberships(Integer userId) { + List memberships = clubMemberRepository.findAllByUserId(userId); + if (memberships.isEmpty()) { + return; + } + + Map membershipByClubId = memberships.stream() + .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); + + List rooms = resolveOrCreateClubRooms(memberships).stream() + .sorted(Comparator.comparing(ChatRoom::getId)) + .toList(); + List roomIds = rooms.stream().map(ChatRoom::getId).toList(); + if (roomIds.isEmpty()) { + return; + } + + Map memberByRoomId = chatRoomMemberRepository + .findByChatRoomIdsAndUserId(roomIds, userId) + .stream() + .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); + + for (ChatRoom room : rooms) { + ClubMember member = membershipByClubId.get(room.getClub().getId()); + if (member == null) { + continue; + } + + ChatRoomMember existingMember = memberByRoomId.get(room.getId()); + if (existingMember != null) { + LocalDateTime lastReadAt = existingMember.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { + chatRoomMemberRepository.updateLastReadAtIfOlder( + room.getId(), userId, member.getCreatedAt() + ); + } + continue; + } + + saveRoomMemberIgnoringDuplicate(room, member.getUser(), member.getCreatedAt()); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateDirectRoomLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { + User user = userRepository.getById(userId); + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + ensureDirectRoomMemberExists(room, user, readAt); + + if (user.getRole() == UserRole.ADMIN) { + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + boolean isSystemAdmin = members.stream() + .anyMatch(member -> Objects.equals(member.getUserId(), SYSTEM_ADMIN_ID)); + + if (isSystemAdmin) { + for (ChatRoomMember member : members) { + if (member.getUser().getRole() == UserRole.ADMIN) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, member.getUserId(), readAt); + } + } + return; + } + } + + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void ensureClubRoomMember(Integer roomId, Integer userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + if (!room.isGroupRoom() || room.getClub() == null) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + ensureMember(room, member.getUser(), member.getCreatedAt()); + } + + private ChatRoom findOrCreateClubRoom(Club club) { + return chatRoomRepository.findByClubId(club.getId()) + .orElseGet(() -> { + try { + return chatRoomRepository.save(ChatRoom.groupOf(club)); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyException(e)) { + throw e; + } + log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", club.getId()); + return chatRoomRepository.findByClubId(club.getId()) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + } + }); + } + + private List resolveOrCreateClubRooms(List memberships) { + Map clubById = memberships.stream() + .map(ClubMember::getClub) + .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); + + Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) + .stream() + .filter(room -> room.getClub() != null) + .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); + + for (Map.Entry clubEntry : clubById.entrySet()) { + if (roomByClubId.containsKey(clubEntry.getKey())) { + continue; + } + try { + ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.groupOf(clubEntry.getValue())); + roomByClubId.put(clubEntry.getKey(), createdRoom); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyException(e)) { + throw e; + } + log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", clubEntry.getKey()); + chatRoomRepository.findByClubId(clubEntry.getKey()) + .ifPresent(room -> roomByClubId.put(clubEntry.getKey(), room)); + } + } + + return memberships.stream() + .map(membership -> roomByClubId.get(membership.getClub().getId())) + .filter(Objects::nonNull) + .toList(); + } + private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { @@ -44,12 +206,50 @@ private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { if (lastReadAt == null || lastReadAt.isBefore(baseline)) { member.updateLastReadAt(baseline); } - }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline))); + }, () -> saveRoomMemberIgnoringDuplicate(room, user, baseline)); } - @Transactional - public void removeClubMember(Integer clubId, Integer userId) { - chatRoomRepository.findByClubId(clubId) - .ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId)); + private void saveRoomMemberIgnoringDuplicate(ChatRoom room, User user, LocalDateTime baseline) { + try { + chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline)); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyException(e)) { + throw e; + } + log.debug("채팅방 멤버 동시 생성 감지, 무시: roomId={}, userId={}", room.getId(), user.getId()); + } + } + + private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTime readAt) { + boolean exists = chatRoomMemberRepository.existsByChatRoomIdAndUserId(room.getId(), user.getId()); + if (exists) { + return; + } + + if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room.getId())) { + saveRoomMemberIgnoringDuplicate(room, user, readAt); + return; + } + + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + private boolean isSystemAdminRoom(Integer roomId) { + List memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId)); + return memberIds.stream() + .map(row -> (Integer)row[1]) + .anyMatch(userId -> userId.equals(SYSTEM_ADMIN_ID)); + } + + private boolean isDuplicateKeyException(DataIntegrityViolationException e) { + if (e instanceof DuplicateKeyException) { + return true; + } + Throwable rootCause = e.getRootCause(); + if (rootCause == null) { + return false; + } + String message = rootCause.getMessage(); + return message != null && (message.contains("Duplicate") || message.contains("duplicate key")); } } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index da2c2732..0d5cc2c2 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -43,7 +44,6 @@ import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.repository.RoomUnreadCountProjection; -import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.notification.enums.NotificationTargetType; @@ -56,7 +56,9 @@ import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -71,6 +73,7 @@ public class ChatService { private final ClubMemberRepository clubMemberRepository; private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; + private final ChatRoomMembershipService chatRoomMembershipService; private final NotificationService notificationService; private final ApplicationEventPublisher eventPublisher; @@ -148,8 +151,9 @@ public void leaveChatRoom(Integer userId, Integer roomId) { chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, userId); } - @Transactional public ChatRoomsSummaryResponse getChatRooms(Integer userId) { + chatRoomMembershipService.ensureClubRoomMemberships(userId); + List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); @@ -191,15 +195,22 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { return new ChatRoomsSummaryResponse(rooms); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + LocalDateTime readAt = LocalDateTime.now(); + if (room.isDirectRoom()) { - return getDirectChatRoomMessages(userId, roomId, page, limit); + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, userId, readAt); + recordPresenceSafely(roomId, userId); + return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); } + chatRoomMembershipService.ensureClubRoomMember(roomId, userId); + chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); + recordPresenceSafely(roomId, userId); return getClubMessagesByRoomId(roomId, userId, page, limit); } @@ -324,32 +335,60 @@ private List getAdminDirectChatRooms() { .toList(); } + private List getClubChatRooms(Integer userId) { + List memberships = clubMemberRepository.findAllByUserId(userId); + if (memberships.isEmpty()) { + return List.of(); + } + + List clubIds = memberships.stream() + .map(cm -> cm.getClub().getId()) + .toList(); + + List rooms = chatRoomRepository.findByClubIds(new ArrayList<>(clubIds)) + .stream() + .filter(room -> room.getClub() != null) + .toList(); + + List roomIds = rooms.stream().map(ChatRoom::getId).toList(); + Map lastMessageMap = getLastMessageMap(roomIds); + Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); + + return rooms.stream() + .map(room -> { + ChatMessage lastMessage = lastMessageMap.get(room.getId()); + return new ChatRoomSummaryResponse( + room.getId(), + ChatType.GROUP, + room.getClub().getName(), + room.getClub().getImageUrl(), + lastMessage != null ? lastMessage.getContent() : null, + lastMessage != null ? lastMessage.getCreatedAt() : null, + unreadCountMap.getOrDefault(room.getId(), 0), + false + ); + }) + .toList(); + } + private ChatMessagePageResponse getDirectChatRoomMessages( Integer userId, Integer roomId, Integer page, - Integer limit + Integer limit, + LocalDateTime readAt ) { ChatRoom chatRoom = getDirectRoom(roomId); User user = userRepository.getById(userId); ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(member, chatRoom); - LocalDateTime readAt = LocalDateTime.now(); - chatPresenceService.recordPresence(roomId, userId); - boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom); PageRequest pageable = PageRequest.of(page - 1, limit); Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); List members = chatRoomMemberRepository.findByChatRoomId(roomId); - if (isAdminViewingSystemRoom) { - updateAllAdminMembersLastReadAt(members, readAt); - } else { - member.updateLastReadAt(readAt); - } - List sortedReadBaselines = isAdminViewingSystemRoom ? toAdminChatReadBaselines(members) : toSortedReadBaselines(members); @@ -394,7 +433,7 @@ private ChatMessageDetailResponse sendDirectMessage( ChatRoomMember senderMember = getAccessibleDirectRoomMember(chatRoom, sender); boolean senderHadLeft = senderMember.hasLeft(); List members = chatRoomMemberRepository.findByChatRoomId(roomId); - User receiver = resolveMessageReceiver(sender, members); + User receiver = resolveDirectChatPartner(members, userId); ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, sender, request.content()) @@ -425,39 +464,6 @@ private ChatMessageDetailResponse sendDirectMessage( ); } - private List getClubChatRooms(Integer userId) { - List memberships = clubMemberRepository.findAllByUserId(userId); - if (memberships.isEmpty()) { - return List.of(); - } - - Map membershipByClubId = memberships.stream() - .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); - - List rooms = resolveOrCreateClubRooms(memberships); - ensureClubRoomMembers(rooms, membershipByClubId, userId); - - List roomIds = rooms.stream().map(ChatRoom::getId).toList(); - Map lastMessageMap = getLastMessageMap(roomIds); - Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); - - return rooms.stream() - .map(room -> { - ChatMessage lastMessage = lastMessageMap.get(room.getId()); - return new ChatRoomSummaryResponse( - room.getId(), - ChatType.GROUP, - room.getClub().getName(), - room.getClub().getImageUrl(), - lastMessage != null ? lastMessage.getContent() : null, - lastMessage != null ? lastMessage.getCreatedAt() : null, - unreadCountMap.getOrDefault(room.getId(), 0), - false - ); - }) - .toList(); - } - private ChatMessagePageResponse getClubMessagesByRoomId( Integer roomId, Integer userId, @@ -465,11 +471,6 @@ private ChatMessagePageResponse getClubMessagesByRoomId( Integer limit ) { ChatRoom room = getClubRoom(roomId); - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); - - chatPresenceService.recordPresence(roomId, userId); - updateLastReadAt(roomId, userId, LocalDateTime.now()); PageRequest pageable = PageRequest.of(page - 1, limit); long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); @@ -594,63 +595,6 @@ private ChatRoom getClubRoom(Integer roomId) { return room; } - private List resolveOrCreateClubRooms(List memberships) { - Map clubById = memberships.stream() - .map(ClubMember::getClub) - .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); - - Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) - .stream() - .filter(room -> room.getClub() != null) - .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); - - for (Map.Entry clubEntry : clubById.entrySet()) { - if (roomByClubId.containsKey(clubEntry.getKey())) { - continue; - } - - ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.groupOf(clubEntry.getValue())); - roomByClubId.put(clubEntry.getKey(), createdRoom); - } - - return memberships.stream() - .map(membership -> roomByClubId.get(membership.getClub().getId())) - .toList(); - } - - private void ensureClubRoomMembers( - List rooms, - Map membershipByClubId, - Integer userId - ) { - if (rooms.isEmpty()) { - return; - } - - Map memberByRoomId = chatRoomMemberRepository - .findByChatRoomIdsAndUserId(extractChatRoomIds(rooms), userId) - .stream() - .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); - - for (ChatRoom room : rooms) { - ClubMember member = membershipByClubId.get(room.getClub().getId()); - if (member == null) { - continue; - } - - ChatRoomMember existingMember = memberByRoomId.get(room.getId()); - if (existingMember != null) { - LocalDateTime lastReadAt = existingMember.getLastReadAt(); - if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { - existingMember.updateLastReadAt(member.getCreatedAt()); - } - continue; - } - - chatRoomMemberRepository.save(ChatRoomMember.of(room, member.getUser(), member.getCreatedAt())); - } - } - private List extractChatRoomIds(List chatRooms) { return chatRooms.stream() .map(ChatRoom::getId) @@ -674,23 +618,6 @@ private Map getUnreadCountMap(List chatRoomIds, Integ )); } - private Map getAdminUnreadCountMap(List chatRoomIds) { - if (chatRoomIds.isEmpty()) { - return Map.of(); - } - - List unreadMessageCounts = chatMessageRepository.countUnreadMessagesForAdmin( - chatRoomIds, - UserRole.ADMIN - ); - - return unreadMessageCounts.stream() - .collect(Collectors.toMap( - UnreadMessageCount::chatRoomId, - unreadMessageCount -> unreadMessageCount.unreadCount().intValue() - )); - } - private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { if (user.getRole() == UserRole.ADMIN) { return null; @@ -820,14 +747,6 @@ private List toAdminChatReadBaselines(List member return baselines; } - private void updateAllAdminMembersLastReadAt(List members, LocalDateTime readAt) { - for (ChatRoomMember member : members) { - if (member.getUser().getRole() == UserRole.ADMIN) { - member.updateLastReadAt(readAt); - } - } - } - private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { int left = 0; int right = sortedReadBaselines.size(); @@ -1055,14 +974,6 @@ private User resolveDirectChatPartner( return findDirectPartnerFromMemberInfo(memberInfos, userId, userMap); } - private User findNonAdminMember(List members) { - return members.stream() - .map(ChatRoomMember::getUser) - .filter(memberUser -> memberUser.getRole() != UserRole.ADMIN) - .findFirst() - .orElse(null); - } - private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap) { return memberInfos.stream() .sorted(Comparator.comparing(MemberInfo::createdAt)) @@ -1073,21 +984,6 @@ private User findNonAdminUserFromMemberInfo(List memberInfos, Map members) { - if (sender.getRole() == UserRole.ADMIN) { - User nonAdminUser = findNonAdminMember(members); - if (nonAdminUser != null) { - return nonAdminUser; - } - } - - User partner = findDirectPartner(members, sender.getId()); - if (partner == null) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - return partner; - } - private User resolveMessageReceiverFromMemberInfo( User sender, List memberInfos, @@ -1107,4 +1003,11 @@ private User resolveMessageReceiverFromMemberInfo( return partner; } + private void recordPresenceSafely(Integer roomId, Integer userId) { + try { + chatPresenceService.recordPresence(roomId, userId); + } catch (Exception e) { + log.warn("Redis presence record failed, continuing: roomId={}, userId={}", roomId, userId, e); + } + } } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index a597771e..c09cdf49 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -73,7 +73,7 @@ public void deleteToken(Integer userId, NotificationTokenDeleteRequest request) .ifPresent(notificationDeviceTokenRepository::delete); } - @Async + @Async("notificationTaskExecutor") @Transactional public void sendChatNotification(Integer receiverId, Integer roomId, String senderName, String messageContent) { try { @@ -123,7 +123,7 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send } } - @Async + @Async("notificationTaskExecutor") @Transactional public void sendGroupChatNotification( Integer roomId, @@ -210,7 +210,7 @@ public void sendGroupChatNotification( } } - @Async + @Async("notificationTaskExecutor") @Transactional public void sendClubApplicationSubmittedNotification( Integer receiverId, @@ -227,7 +227,7 @@ public void sendClubApplicationSubmittedNotification( sendNotification(receiverId, clubName, body, path); } - @Async + @Async("notificationTaskExecutor") @Transactional public void sendClubApplicationApprovedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 승인되었어요."; @@ -238,7 +238,7 @@ public void sendClubApplicationApprovedNotification(Integer receiverId, Integer sendNotification(receiverId, clubName, body, path); } - @Async + @Async("notificationTaskExecutor") @Transactional public void sendClubApplicationRejectedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 거절되었어요."; diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java index bfc63895..f88deb3e 100644 --- a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -1,20 +1,61 @@ package gg.agit.konect.global.config; import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Configuration -public class AsyncConfig { +public class AsyncConfig implements AsyncConfigurer { + + private static final int DEFAULT_CORE_POOL_SIZE = 2; + private static final int DEFAULT_MAX_POOL_SIZE = 5; + private static final int DEFAULT_QUEUE_CAPACITY = 50; + private static final int DEFAULT_AWAIT_TERMINATION_SECONDS = 30; private static final int SHEET_SYNC_CORE_POOL_SIZE = 2; private static final int SHEET_SYNC_MAX_POOL_SIZE = 4; private static final int SHEET_SYNC_QUEUE_CAPACITY = 50; private static final int SHEET_SYNC_AWAIT_TERMINATION_SECONDS = 30; + private static final int NOTIFICATION_CORE_POOL_SIZE = 2; + private static final int NOTIFICATION_MAX_POOL_SIZE = 5; + private static final int NOTIFICATION_QUEUE_CAPACITY = 100; + private static final int NOTIFICATION_AWAIT_TERMINATION_SECONDS = 30; + + private static final int SLACK_CORE_POOL_SIZE = 1; + private static final int SLACK_MAX_POOL_SIZE = 3; + private static final int SLACK_QUEUE_CAPACITY = 50; + private static final int SLACK_AWAIT_TERMINATION_SECONDS = 30; + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(DEFAULT_CORE_POOL_SIZE); + executor.setMaxPoolSize(DEFAULT_MAX_POOL_SIZE); + executor.setQueueCapacity(DEFAULT_QUEUE_CAPACITY); + executor.setThreadNamePrefix("async-default-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(DEFAULT_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new SimpleAsyncUncaughtExceptionHandler(); + } + @Bean(name = "sheetSyncTaskExecutor") public Executor sheetSyncTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); @@ -28,4 +69,40 @@ public Executor sheetSyncTaskExecutor() { executor.initialize(); return executor; } + + @Bean(name = "notificationTaskExecutor") + public Executor notificationTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(NOTIFICATION_CORE_POOL_SIZE); + executor.setMaxPoolSize(NOTIFICATION_MAX_POOL_SIZE); + executor.setQueueCapacity(NOTIFICATION_QUEUE_CAPACITY); + executor.setThreadNamePrefix("notification-"); + executor.setRejectedExecutionHandler((runnable, pool) -> { + log.warn("알림 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", + pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); + throw new RejectedExecutionException("notificationTaskExecutor saturated"); + }); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(NOTIFICATION_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } + + @Bean(name = "slackTaskExecutor") + public Executor slackTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(SLACK_CORE_POOL_SIZE); + executor.setMaxPoolSize(SLACK_MAX_POOL_SIZE); + executor.setQueueCapacity(SLACK_QUEUE_CAPACITY); + executor.setThreadNamePrefix("slack-"); + executor.setRejectedExecutionHandler((runnable, pool) -> { + log.warn("Slack 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", + pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); + throw new RejectedExecutionException("slackTaskExecutor saturated"); + }); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(SLACK_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java index c984ef59..d050a21e 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -81,7 +81,7 @@ public List> fetchAIThreadReplies(String channelId, String t return new ArrayList<>(); } - @Async + @Async("slackTaskExecutor") public void processAIQuery(String text, String channelId, String threadTs, List> cachedReplies) { try { diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java index c2b7d9ae..44b6774c 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java @@ -16,7 +16,7 @@ public class ChatSlackListener { private final SlackNotificationService slackNotificationService; - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleAdminChatReceived(AdminChatReceivedEvent event) { slackNotificationService.notifyAdminChatReceived(event.senderName(), event.content()); diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java index 578c2b22..32f98195 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java @@ -16,7 +16,7 @@ public class InquirySlackListener { private final SlackNotificationService slackNotificationService; - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleInquirySubmitted(InquirySubmittedEvent event) { slackNotificationService.notifyInquiry(event.content()); diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java index eea11dc8..8b5caa24 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java @@ -17,13 +17,13 @@ public class UserSlackListener { private final SlackNotificationService slackNotificationService; - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleUserWithdrawn(UserWithdrawnEvent event) { slackNotificationService.notifyUserWithdraw(event.email(), event.provider()); } - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleUserRegistered(UserRegisteredEvent event) { slackNotificationService.notifyUserRegister(event.email(), event.provider()); diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index 404202a1..de2bb656 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -9,6 +9,11 @@ spring: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} password: ${MYSQL_PASSWORD} + hikari: + maximum-pool-size: 20 + minimum-idle: 10 + connection-timeout: 5000 + leak-detection-threshold: 30000 jpa: properties: From 70ee2e8ad02c885d2fdd803c151a38e96eaaf31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:38:57 +0900 Subject: [PATCH 41/55] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=EC=97=90=20=EC=B4=88=EB=8C=80=20=EA=B0=80=EB=8A=A5=ED=95=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 새 채팅방 초대 가능 사용자 목록 조회 API 추가 * feat: 초대 가능 사용자 목록 정렬 및 그룹화 로직 추가 * test: 초대 가능 사용자 조회 테스트 케이스 추가 * refactor: 초대 가능 사용자 조회 로직 QueryDSL로 개선 * feat: 초대 가능 사용자 목록 페이징 처리 추가 * test: 초대 가능 사용자 목록 페이징 테스트 추가 * refactor: 초대 가능 사용자 조회 설명 주석 추가 * refactor: 초대 가능 사용자 조회 로직 구조 개선 * test: 초대 가능 사용자 목록 클럽 페이징 테스트 추가 * refactor: 초대 가능 사용자 조회 쿼리 distinct 제거 * chore: 코드 포맷팅 * refactor: 초대 가능 사용자 조회 쿼리에 groupBy 추가 * refactor: 대표 동아리 필터링 불필요 로직 제거 * refactor: 미사용 초대 가능 사용자 조회 메서드 삭제 --- .../domain/chat/controller/ChatApi.java | 22 ++ .../chat/controller/ChatController.java | 14 + .../chat/dto/ChatInvitableUsersResponse.java | 100 +++++++ .../domain/chat/enums/ChatInviteSortBy.java | 6 + .../repository/ChatInviteQueryRepository.java | 229 ++++++++++++++++ .../domain/chat/service/ChatService.java | 121 ++++++++- .../integration/domain/chat/ChatApiTest.java | 253 ++++++++++++++++++ 7 files changed, 738 insertions(+), 7 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatInvitableUsersResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/enums/ChatInviteSortBy.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/repository/ChatInviteQueryRepository.java diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 3f906215..a6fa42b0 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; @@ -18,6 +19,7 @@ import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -77,6 +79,26 @@ ResponseEntity getChatRooms( @UserId Integer userId ); + @Operation(summary = "새 채팅방에 초대할 수 있는 사용자 목록을 조회한다.", description = """ + ## 설명 + - 현재 사용자가 속해 있는 채팅방들의 멤버를 기반으로 초대 가능 사용자 목록을 조회합니다. + - 자기 자신, 탈퇴 사용자, 채팅방을 떠난 사용자는 제외됩니다. + - 관리자 계정은 초대 대상에서 제외됩니다. + - `sortBy=CLUB`이면 동아리 섹션별로 그룹핑되어 응답합니다. + - `sortBy=NAME`이면 동아리 섹션 없이 이름순 단일 리스트로 응답합니다. + - 검색어(query)는 이름과 학번에 대해 부분 일치로 동작합니다. + """) + @GetMapping("/rooms/invitables") + ResponseEntity getInvitableUsers( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "sortBy", defaultValue = "CLUB") ChatInviteSortBy sortBy, + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @RequestParam(name = "page", defaultValue = "1") Integer page, + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, + @UserId Integer userId + ); + @Operation(summary = "채팅방 메시지 리스트를 조회한다.", description = """ ## 설명 - 특정 채팅방의 메시지 목록을 페이지네이션으로 조회합니다. diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index dc4a79b0..861f8f40 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -11,11 +11,13 @@ import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -54,6 +56,18 @@ public ResponseEntity getChatRooms( return ResponseEntity.ok(response); } + @Override + public ResponseEntity getInvitableUsers( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "sortBy", defaultValue = "CLUB") ChatInviteSortBy sortBy, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, + @UserId Integer userId + ) { + ChatInvitableUsersResponse response = chatService.getInvitableUsers(userId, query, sortBy, page, limit); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getChatRoomMessages( @RequestParam(name = "page", defaultValue = "1") Integer page, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatInvitableUsersResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatInvitableUsersResponse.java new file mode 100644 index 00000000..cadcd02e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatInvitableUsersResponse.java @@ -0,0 +1,100 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; +import gg.agit.konect.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatInvitableUsersResponse( + @Schema(description = "조건에 해당하는 전체 초대 가능 사용자 수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 초대 가능 사용자 수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "정렬 기준", example = "CLUB", requiredMode = REQUIRED) + ChatInviteSortBy sortBy, + + @Schema(description = "동아리 섹션 그룹핑 여부", example = "true", requiredMode = REQUIRED) + boolean grouped, + + @Schema(description = "이름순 정렬일 때 반환되는 초대 가능 사용자 리스트", requiredMode = REQUIRED) + List users, + + @Schema(description = "동아리순 정렬일 때 반환되는 섹션 리스트", requiredMode = REQUIRED) + List sections +) { + + public record InvitableUser( + @Schema(description = "유저 ID", example = "1", requiredMode = REQUIRED) + Integer userId, + + @Schema(description = "이름", example = "최승운", requiredMode = REQUIRED) + String name, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.png", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "학번", example = "2021234567", requiredMode = REQUIRED) + String studentNumber + ) { + public static InvitableUser from(User user) { + return new InvitableUser( + user.getId(), + user.getName(), + user.getImageUrl(), + user.getStudentNumber() + ); + } + } + + public record InvitableSection( + @Schema(description = "동아리 ID, 기타 섹션이면 null", example = "3", requiredMode = NOT_REQUIRED) + Integer clubId, + + @Schema(description = "섹션 이름", example = "BCSD", requiredMode = REQUIRED) + String clubName, + + @Schema(description = "해당 섹션의 초대 가능 사용자 리스트", requiredMode = REQUIRED) + List users + ) { + } + + public static ChatInvitableUsersResponse forNameSort(Page page) { + return new ChatInvitableUsersResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + ChatInviteSortBy.NAME, + false, + page.getContent(), + List.of() + ); + } + + public static ChatInvitableUsersResponse forClubSort(Page page, List sections) { + return new ChatInvitableUsersResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + ChatInviteSortBy.CLUB, + true, + List.of(), + sections + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/enums/ChatInviteSortBy.java b/src/main/java/gg/agit/konect/domain/chat/enums/ChatInviteSortBy.java new file mode 100644 index 00000000..9f7f4e72 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/enums/ChatInviteSortBy.java @@ -0,0 +1,6 @@ +package gg.agit.konect.domain.chat.enums; + +public enum ChatInviteSortBy { + NAME, + CLUB +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatInviteQueryRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatInviteQueryRepository.java new file mode 100644 index 00000000..01cd6dd9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatInviteQueryRepository.java @@ -0,0 +1,229 @@ +package gg.agit.konect.domain.chat.repository; + +import static gg.agit.konect.domain.club.model.QClub.club; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.chat.model.QChatRoomMember; +import gg.agit.konect.domain.club.model.QClub; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.QClubMember; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.QUser; +import gg.agit.konect.domain.user.model.User; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ChatInviteQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Page findInvitableUsers(Integer userId, String query, PageRequest pageRequest) { + QChatRoomMember requesterMember = new QChatRoomMember("requesterMember"); + QChatRoomMember candidateMember = new QChatRoomMember("candidateMember"); + QUser candidateUser = new QUser("candidateUser"); + + List content = createInvitableUsersQuery(userId, query, requesterMember, candidateMember, candidateUser) + .groupBy( + candidateUser.id, + candidateUser.name, + candidateUser.imageUrl, + candidateUser.studentNumber + ) + .orderBy( + candidateUser.name.asc(), + candidateUser.studentNumber.asc(), + candidateUser.id.asc() + ) + .offset(pageRequest.getOffset()) + .limit(pageRequest.getPageSize()) + .fetch(); + + Long total = createInvitableUsersCountQuery(userId, query, requesterMember, candidateMember, candidateUser) + .fetchOne(); + + return new PageImpl<>(content, pageRequest, total == null ? 0 : total); + } + + public Page findInvitableUserIdsGroupedByClub( + Integer userId, + String query, + PageRequest pageRequest + ) { + QChatRoomMember requesterMember = new QChatRoomMember("requesterMember"); + QChatRoomMember candidateMember = new QChatRoomMember("candidateMember"); + QUser candidateUser = new QUser("candidateUser"); + QClubMember requesterClubMember = new QClubMember("requesterClubMember"); + QClubMember candidateClubMember = new QClubMember("candidateClubMember"); + QClub sharedClub = new QClub("sharedClub"); + + StringExpression representativeClubName = new CaseBuilder() + .when(requesterClubMember.id.isNotNull()) + .then(sharedClub.name) + .otherwise((String)null); + NumberExpression clubPresenceOrder = new CaseBuilder() + .when(requesterClubMember.id.isNotNull()) + .then(0) + .otherwise(1); + + List content = createInvitableUsersQuery( + userId, + query, + requesterMember, + candidateMember, + candidateUser + ) + // 사용자가 여러 동아리에 속해도 대표 정렬 키는 + // 요청자와 실제로 공유하는 동아리만 기준으로 계산해야 한다. + .leftJoin(candidateClubMember) + .on(candidateClubMember.user.id.eq(candidateUser.id)) + .leftJoin(candidateClubMember.club, sharedClub) + .leftJoin(requesterClubMember) + .on( + requesterClubMember.club.id.eq(sharedClub.id) + .and(requesterClubMember.user.id.eq(userId)) + ) + .groupBy( + candidateUser.id, + candidateUser.name, + candidateUser.imageUrl, + candidateUser.studentNumber + ) + .orderBy( + // 공유 동아리가 있는 사용자를 먼저 두고, + // 그 안에서는 대표 동아리 이름 → 사용자 이름 순으로 페이지 경계를 고정한다. + clubPresenceOrder.min().asc(), + representativeClubName.min().asc(), + candidateUser.name.asc(), + candidateUser.studentNumber.asc(), + candidateUser.id.asc() + ) + .select(candidateUser.id) + .offset(pageRequest.getOffset()) + .limit(pageRequest.getPageSize()) + .fetch(); + + Long total = createInvitableUsersCountQuery(userId, query, requesterMember, candidateMember, candidateUser) + .fetchOne(); + + return new PageImpl<>(content, pageRequest, total == null ? 0 : total); + } + + private JPAQuery createInvitableUsersQuery( + Integer userId, + String query, + QChatRoomMember requesterMember, + QChatRoomMember candidateMember, + QUser candidateUser + ) { + return jpaQueryFactory.select(candidateUser) + .from(requesterMember) + .join(candidateMember) + .on(candidateMember.chatRoom.id.eq(requesterMember.chatRoom.id)) + .join(candidateMember.user, candidateUser) + .where( + requesterMember.user.id.eq(userId), + requesterMember.leftAt.isNull(), + candidateMember.user.id.ne(userId), + candidateMember.leftAt.isNull(), + candidateUser.deletedAt.isNull(), + candidateUser.role.ne(UserRole.ADMIN), + containsUserKeyword(candidateUser, query) + ); + } + + private JPAQuery createInvitableUsersCountQuery( + Integer userId, + String query, + QChatRoomMember requesterMember, + QChatRoomMember candidateMember, + QUser candidateUser + ) { + // offset/limit가 적용된 본문 쿼리만으로는 전체 개수를 알 수 없어 count 쿼리를 분리한다. + return jpaQueryFactory.select(candidateUser.id.countDistinct()) + .from(requesterMember) + .join(candidateMember) + .on(candidateMember.chatRoom.id.eq(requesterMember.chatRoom.id)) + .join(candidateMember.user, candidateUser) + .where( + requesterMember.user.id.eq(userId), + requesterMember.leftAt.isNull(), + candidateMember.user.id.ne(userId), + candidateMember.leftAt.isNull(), + candidateUser.deletedAt.isNull(), + candidateUser.role.ne(UserRole.ADMIN), + containsUserKeyword(candidateUser, query) + ); + } + + public List findRequesterClubMemberships(Integer userId) { + QClubMember requesterClubMember = new QClubMember("requesterClubMember"); + + // 서비스가 섹션 이름과 순서를 바로 쓸 수 있게 요청자 동아리를 fetch join으로 한 번에 읽는다. + return jpaQueryFactory.select(requesterClubMember) + .from(requesterClubMember) + .join(requesterClubMember.club, club).fetchJoin() + .join(requesterClubMember.user).fetchJoin() + .where(requesterClubMember.user.id.eq(userId)) + .orderBy(club.name.asc(), club.id.asc()) + .fetch(); + } + + public List findSharedClubMemberships(Integer userId, List candidateUserIds) { + if (candidateUserIds.isEmpty()) { + return List.of(); + } + + QClubMember requesterClubMember = new QClubMember("requesterClubMember"); + QClubMember candidateClubMember = new QClubMember("candidateClubMember"); + QUser candidateUser = new QUser("candidateUser"); + + // 대표 섹션 후보는 요청자와 실제로 공유하는 동아리만 남겨야 하므로 club_member를 다시 조인한다. + return jpaQueryFactory.select(candidateClubMember) + .from(candidateClubMember) + .join(candidateClubMember.club, club).fetchJoin() + .join(candidateClubMember.user, candidateUser).fetchJoin() + .join(requesterClubMember) + .on( + requesterClubMember.club.id.eq(candidateClubMember.club.id) + .and(requesterClubMember.user.id.eq(userId)) + ) + .where( + // 현재 페이지 후보 집합 안에서만 대표 동아리를 고르면 서비스 단계의 중복 배치를 막을 수 있다. + candidateClubMember.user.id.in(candidateUserIds), + candidateUser.deletedAt.isNull() + ) + // putIfAbsent로 첫 동아리를 대표값으로 고를 수 있도록 동아리명/이름순으로 정렬한다. + .orderBy( + club.name.asc(), + candidateUser.name.asc(), + candidateUser.studentNumber.asc(), + candidateUser.id.asc() + ) + .fetch(); + } + + private BooleanExpression containsUserKeyword(QUser candidateUser, String query) { + if (!StringUtils.hasText(query)) { + return null; + } + + String normalizedQuery = query.trim().toLowerCase(); + return candidateUser.name.lower().contains(normalizedQuery) + .or(candidateUser.studentNumber.contains(normalizedQuery)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 0d5cc2c2..d1f8b1ea 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -1,15 +1,12 @@ package gg.agit.konect.domain.chat.service; -import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_SELF; -import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_LEAVE_GROUP_CHAT_ROOM; -import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; -import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; -import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; +import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -18,28 +15,32 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; -import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; -import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.dto.UnreadMessageCount; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.event.AdminChatReceivedEvent; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; @@ -65,12 +66,14 @@ public class ChatService { private static final int SYSTEM_ADMIN_ID = 1; + private static final String ETC_SECTION_NAME = "기타"; private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; private final NotificationMuteSettingRepository notificationMuteSettingRepository; private final ClubMemberRepository clubMemberRepository; + private final ChatInviteQueryRepository chatInviteQueryRepository; private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; private final ChatRoomMembershipService chatRoomMembershipService; @@ -195,6 +198,110 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { return new ChatRoomsSummaryResponse(rooms); } + public ChatInvitableUsersResponse getInvitableUsers( + Integer userId, + String query, + ChatInviteSortBy sortBy, + Integer page, + Integer limit + ) { + userRepository.getById(userId); + PageRequest pageRequest = PageRequest.of(page - 1, limit); + + if (sortBy == ChatInviteSortBy.CLUB) { + return getInvitableUsersGroupedByClub(userId, query, pageRequest); + } + + Page filteredUserEntitiesPage = chatInviteQueryRepository.findInvitableUsers(userId, query, pageRequest); + + // 응답 DTO는 채팅 초대 화면에서 바로 쓰는 최소 필드만 유지한다. + List filteredUsers = filteredUserEntitiesPage.getContent().stream() + .map(ChatInvitableUsersResponse.InvitableUser::from) + .toList(); + + // 응답 메타(total/current page 정보)는 유지하면서 내용만 DTO로 치환한다. + Page filteredUsersPage = new PageImpl<>( + filteredUsers, + pageRequest, + filteredUserEntitiesPage.getTotalElements() + ); + + return ChatInvitableUsersResponse.forNameSort(filteredUsersPage); + } + + private ChatInvitableUsersResponse getInvitableUsersGroupedByClub( + Integer userId, + String query, + PageRequest pageRequest + ) { + // CLUB 정렬은 DB가 현재 페이지에 들어갈 userId까지 잘라 오고, + // 서비스는 그 결과를 섹션 응답으로만 복원한다. + Page pagedUserIds = chatInviteQueryRepository.findInvitableUserIdsGroupedByClub( + userId, + query, + pageRequest + ); + + if (pagedUserIds.isEmpty()) { + return ChatInvitableUsersResponse.forClubSort( + new PageImpl<>(List.of(), pageRequest, pagedUserIds.getTotalElements()), + List.of() + ); + } + + // IN 조회는 정렬 순서를 보장하지 않으므로, DB가 정한 userId 페이지 순서대로 다시 조립한다. + Map pagedUserMap = userRepository.findAllByIdIn(pagedUserIds.getContent()).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + + List pagedUsers = pagedUserIds.getContent().stream() + .map(pagedUserMap::get) + .filter(Objects::nonNull) + .map(ChatInvitableUsersResponse.InvitableUser::from) + .toList(); + + Page pagedInvitableUsers = new PageImpl<>( + pagedUsers, + pageRequest, + pagedUserIds.getTotalElements() + ); + + record SectionKey(Integer clubId, String clubName) { + } + + Map representativeClubByUserId = new HashMap<>(); + Map representativeClubNames = new HashMap<>(); + // 현재 페이지 사용자에 대해서만 대표 동아리를 다시 구해도, + // userId 자체는 이미 대표 동아리 기준으로 정렬돼 있으므로 페이지 경계는 유지된다. + chatInviteQueryRepository.findSharedClubMemberships(userId, pagedUserIds.getContent()).stream() + .forEach(clubMember -> { + representativeClubNames.putIfAbsent(clubMember.getClub().getId(), clubMember.getClub().getName()); + representativeClubByUserId.putIfAbsent(clubMember.getUser().getId(), clubMember.getClub().getId()); + }); + + // 대표 동아리가 없는 사용자는 기타 섹션으로 떨어지고, + // 같은 대표 동아리를 가진 사용자끼리만 현재 페이지 sections[]로 묶는다. + Map> sectionMap = new LinkedHashMap<>(); + pagedUsers.forEach(user -> { + Integer representativeClubId = representativeClubByUserId.get(user.userId()); + String clubName = representativeClubId == null + ? ETC_SECTION_NAME + : representativeClubNames.get(representativeClubId); + SectionKey key = new SectionKey(representativeClubId, clubName); + sectionMap.computeIfAbsent(key, ignored -> new ArrayList<>()) + .add(user); + }); + + List sections = sectionMap.entrySet().stream() + .map(entry -> new ChatInvitableUsersResponse.InvitableSection( + entry.getKey().clubId(), + entry.getKey().clubName(), + entry.getValue() + )) + .toList(); + + return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); + } + @Transactional(propagation = Propagation.NOT_SUPPORTED) public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { ChatRoom room = chatRoomRepository.findById(roomId) diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index ddbc120a..fb2caea2 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -6,6 +6,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -26,7 +27,9 @@ import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; import gg.agit.konect.domain.notification.service.NotificationService; @@ -37,6 +40,8 @@ import gg.agit.konect.support.fixture.UniversityFixture; import gg.agit.konect.support.fixture.UserFixture; +import org.springframework.util.LinkedMultiValueMap; + class ChatApiTest extends IntegrationTestSupport { @Autowired @@ -177,6 +182,219 @@ void createAdminChatRoomAndGetRoomsSuccess() throws Exception { } } + @Nested + @DisplayName("GET /chats/rooms/invitables - 초대 가능 사용자 조회") + class GetInvitableUsers { + + private User bcsdUser; + private User cseUser; + private User directOnlyUser; + private User multiClubUser; + private User withdrawnUser; + private User adminCandidate; + + @BeforeEach + void setUpInvitableUsersFixture() { + bcsdUser = createUser("김비씨", "2021136002"); + cseUser = createUser("이씨에스", "2021136003"); + directOnlyUser = createUser("박다이렉트", "2021136004"); + multiClubUser = createUser("정멀티", "2021136006"); + withdrawnUser = createUser("탈퇴예정", "2021136005"); + adminCandidate = persist(UserFixture.createAdmin(university)); + + Club bcsd = persist(ClubFixture.create(university, "BCSD")); + Club cse = persist(ClubFixture.create(university, "CSE&Biz")); + + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + User managedBcsdUser = entityManager.getReference(User.class, bcsdUser.getId()); + User managedCseUser = entityManager.getReference(User.class, cseUser.getId()); + User managedMultiClubUser = entityManager.getReference(User.class, multiClubUser.getId()); + Club managedBcsd = entityManager.getReference(Club.class, bcsd.getId()); + Club managedCse = entityManager.getReference(Club.class, cse.getId()); + + persist(ClubMember.builder() + .club(managedBcsd) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedBcsd) + .user(managedBcsdUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedBcsd) + .user(managedMultiClubUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedCse) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedCse) + .user(managedCseUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedCse) + .user(managedMultiClubUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + + ChatRoom bcsdRoom = persist(ChatRoom.groupOf(bcsd)); + ChatRoom cseRoom = persist(ChatRoom.groupOf(cse)); + createDirectChatRoom(normalUser, directOnlyUser); + createDirectChatRoom(normalUser, adminCandidate); + + addRoomMember(bcsdRoom, normalUser); + addRoomMember(bcsdRoom, bcsdUser); + addRoomMember(bcsdRoom, multiClubUser); + addRoomMember(cseRoom, normalUser); + addRoomMember(cseRoom, cseUser); + addRoomMember(cseRoom, multiClubUser); + addRoomMember(cseRoom, withdrawnUser); + + entityManager.getReference(User.class, withdrawnUser.getId()).withdraw(LocalDateTime.now()); + + clearPersistenceContext(); + } + + @Test + @DisplayName("기본 정렬은 동아리 섹션과 기타 섹션으로 반환한다") + void getInvitableUsersGroupedByClub() throws Exception { + mockLoginUser(normalUser.getId()); + + performGet("/chats/rooms/invitables") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(4)) + .andExpect(jsonPath("$.currentCount").value(4)) + .andExpect(jsonPath("$.totalPage").value(1)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.sortBy").value("CLUB")) + .andExpect(jsonPath("$.grouped").value(true)) + .andExpect(jsonPath("$.users").isEmpty()) + .andExpect(jsonPath("$.sections[0].clubName").value("BCSD")) + .andExpect(jsonPath("$.sections[0].users[0].name").value("김비씨")) + .andExpect(jsonPath("$.sections[0].users[1].name").value("정멀티")) + .andExpect(jsonPath("$.sections[1].clubName").value("CSE&Biz")) + .andExpect(jsonPath("$.sections[1].users[0].name").value("이씨에스")) + .andExpect(jsonPath("$.sections[1].users[1]").doesNotExist()) + .andExpect(jsonPath("$.sections[2].clubName").value("기타")) + .andExpect(jsonPath("$.sections[2].users[0].name").value("박다이렉트")) + .andExpect(jsonPath("$.sections[2].users[1]").doesNotExist()) + .andExpect(jsonPath("$.sections[*].users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("탈퇴예정") + ))) + .andExpect(jsonPath("$.sections[*].users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("관리자") + ))); + } + + @Test + @DisplayName("이름 정렬이면 섹션 없이 단일 리스트로 반환하고 검색이 적용된다") + void getInvitableUsersSortedByName() throws Exception { + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("NAME"), + "query", List.of("2021136004") + )); + + performGet("/chats/rooms/invitables", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.currentCount").value(1)) + .andExpect(jsonPath("$.totalPage").value(1)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.sortBy").value("NAME")) + .andExpect(jsonPath("$.grouped").value(false)) + .andExpect(jsonPath("$.sections").isEmpty()) + .andExpect(jsonPath("$.users[0].name").value("박다이렉트")) + .andExpect(jsonPath("$.users[1]").doesNotExist()) + .andExpect(jsonPath("$.users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("탈퇴예정") + ))) + .andExpect(jsonPath("$.users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("관리자") + ))); + } + + @Test + @DisplayName("페이지네이션을 적용하면 현재 페이지 사용자만 반환한다") + void getInvitableUsersWithPagination() throws Exception { + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("NAME"), + "page", List.of("2"), + "limit", List.of("2") + )); + + performGet("/chats/rooms/invitables", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(4)) + .andExpect(jsonPath("$.currentCount").value(2)) + .andExpect(jsonPath("$.totalPage").value(2)) + .andExpect(jsonPath("$.currentPage").value(2)) + .andExpect(jsonPath("$.users[0].name").value("이씨에스")) + .andExpect(jsonPath("$.users[1].name").value("정멀티")) + .andExpect(jsonPath("$.users[2]").doesNotExist()); + } + + @Test + @DisplayName("동아리 정렬은 페이지 경계가 동아리를 가로질러도 섹션 헤더를 유지한다") + void getInvitableUsersWithClubPaginationAcrossSections() throws Exception { + mockLoginUser(normalUser.getId()); + + createGroupedInviteCandidates("분할A", "분할A", 10); + createGroupedInviteCandidates("분할B", "분할B", 20); + createGroupedInviteCandidates("분할C", "분할C", 30); + clearPersistenceContext(); + + LinkedMultiValueMap firstPageParams = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("CLUB"), + "query", List.of("분할"), + "page", List.of("1"), + "limit", List.of("20") + )); + + performGet("/chats/rooms/invitables", firstPageParams) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(60)) + .andExpect(jsonPath("$.currentCount").value(20)) + .andExpect(jsonPath("$.totalPage").value(3)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.sections[0].clubName").value("분할A")) + .andExpect(jsonPath("$.sections[0].users.length()").value(10)) + .andExpect(jsonPath("$.sections[1].clubName").value("분할B")) + .andExpect(jsonPath("$.sections[1].users.length()").value(10)) + .andExpect(jsonPath("$.sections[2]").doesNotExist()); + + LinkedMultiValueMap secondPageParams = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("CLUB"), + "query", List.of("분할"), + "page", List.of("2"), + "limit", List.of("20") + )); + + performGet("/chats/rooms/invitables", secondPageParams) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(60)) + .andExpect(jsonPath("$.currentCount").value(20)) + .andExpect(jsonPath("$.totalPage").value(3)) + .andExpect(jsonPath("$.currentPage").value(2)) + .andExpect(jsonPath("$.sections[0].clubName").value("분할B")) + .andExpect(jsonPath("$.sections[0].users.length()").value(10)) + .andExpect(jsonPath("$.sections[0].users[0].name").value("분할B11")) + .andExpect(jsonPath("$.sections[0].users[9].name").value("분할B20")) + .andExpect(jsonPath("$.sections[1].clubName").value("분할C")) + .andExpect(jsonPath("$.sections[1].users.length()").value(10)) + .andExpect(jsonPath("$.sections[1].users[0].name").value("분할C01")) + .andExpect(jsonPath("$.sections[1].users[9].name").value("분할C10")) + .andExpect(jsonPath("$.sections[2]").doesNotExist()); + } + } + @Nested @DisplayName("POST /chats/rooms/{chatRoomId}/messages - 메시지 전송") class SendMessage { @@ -566,6 +784,41 @@ private User createUser(String name, String studentId) { return persist(UserFixture.createUser(university, name, studentId)); } + private void addRoomMember(ChatRoom chatRoom, User user) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + persist(ChatRoomMember.of(managedChatRoom, managedUser, chatRoom.getCreatedAt())); + } + + private void createGroupedInviteCandidates(String clubName, String namePrefix, int count) { + Club club = persist(ClubFixture.create(university, clubName)); + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + + persist(ClubMember.builder() + .club(managedClub) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + + ChatRoom groupRoom = persist(ChatRoom.groupOf(club)); + addRoomMember(groupRoom, normalUser); + + for (int index = 1; index <= count; index++) { + User candidate = createUser( + String.format("%s%02d", namePrefix, index), + String.format("202199%04d", index + count * 10) + ); + User managedCandidate = entityManager.getReference(User.class, candidate.getId()); + persist(ClubMember.builder() + .club(managedClub) + .user(managedCandidate) + .clubPosition(ClubPosition.MEMBER) + .build()); + addRoomMember(groupRoom, candidate); + } + } + private long countDirectRoomsBetween(User firstUser, User secondUser) { return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() .map(ChatRoom::getId) From e6ee8e5045f9ceba20bec70385ef2c3a57957fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:42:04 +0900 Subject: [PATCH 42/55] =?UTF-8?q?fix:=20Google=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=20classpath=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#469)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../googlesheets/GoogleSheetsConfig.java | 19 ++++++++++++++++++- .../integration/KonectApplicationTests.java | 18 ++++++++++++++++-- .../support/IntegrationTestSupport.java | 12 ++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index bb85274b..b81e50ef 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -1,6 +1,7 @@ package gg.agit.konect.infrastructure.googlesheets; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.security.GeneralSecurityException; @@ -9,6 +10,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; import com.google.api.client.json.gson.GsonFactory; @@ -27,10 +30,11 @@ public class GoogleSheetsConfig { private final GoogleSheetsProperties googleSheetsProperties; + private final ResourceLoader resourceLoader; @Bean public GoogleCredentials googleCredentials() throws IOException { - try (InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath())) { + try (InputStream in = openCredentialsStream()) { return GoogleCredentials.fromStream(in) .createScoped(Arrays.asList( SheetsScopes.SPREADSHEETS, @@ -81,4 +85,17 @@ public Drive buildUserDriveService(String refreshToken) throws IOException, Gene .setApplicationName(googleSheetsProperties.applicationName()) .build(); } + + private InputStream openCredentialsStream() throws IOException { + String credentialsPath = googleSheetsProperties.credentialsPath(); + if (credentialsPath.startsWith("classpath:")) { + Resource resource = resourceLoader.getResource(credentialsPath); + if (!resource.exists()) { + throw new FileNotFoundException(credentialsPath); + } + return resource.getInputStream(); + } + + return new FileInputStream(credentialsPath); + } } diff --git a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java index f1a93e0e..ac1367ee 100644 --- a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java +++ b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java @@ -5,19 +5,33 @@ import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import com.google.api.services.drive.Drive; +import com.google.api.services.sheets.v4.Sheets; +import com.google.auth.oauth2.GoogleCredentials; import gg.agit.konect.support.TestClaudeConfig; +import gg.agit.konect.support.TestMcpConfig; import gg.agit.konect.support.TestSecurityConfig; @SpringBootTest @ActiveProfiles("test") -@Import({TestClaudeConfig.class, TestSecurityConfig.class}) +@Import({TestClaudeConfig.class, TestMcpConfig.class, TestSecurityConfig.class}) @TestPropertySource(locations = "classpath:.env.test.properties") class KonectApplicationTests { + @MockitoBean + private GoogleCredentials googleCredentials; + + @MockitoBean + private Sheets googleSheetsService; + + @MockitoBean + private Drive googleDriveService; + @Test void contextLoads() { } } - diff --git a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java index 7dc95b20..157a152d 100644 --- a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java +++ b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java @@ -24,6 +24,9 @@ import org.springframework.util.MultiValueMap; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.drive.Drive; +import com.google.api.services.sheets.v4.Sheets; +import com.google.auth.oauth2.GoogleCredentials; import jakarta.persistence.EntityManager; @@ -71,6 +74,15 @@ public abstract class IntegrationTestSupport { @MockitoBean protected LoggingProperties loggingProperties; + @MockitoBean + protected GoogleCredentials googleCredentials; + + @MockitoBean + protected Sheets googleSheetsService; + + @MockitoBean + protected Drive googleDriveService; + @BeforeEach void setUpCommonMocks() throws Exception { given(loginCheckInterceptor.preHandle(any(), any(), any())).willReturn(true); From 5ec7684aafa24e072a44e8ed4592687429f0faab Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:15:59 +0900 Subject: [PATCH 43/55] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=ED=95=A9=20import=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 구글 시트 통합 import api 추가 * fix: 시트 통합 import 트랜잭션 경계 조정 * refactor: 시트 통합 import 분석 결과 재사용 --- .../controller/ClubSheetMigrationApi.java | 13 +++ .../ClubSheetMigrationController.java | 14 +++ .../club/service/ClubMemberSheetService.java | 20 +++++ .../service/ClubSheetIntegratedService.java | 41 +++++++++ .../club/service/SheetImportService.java | 30 ++++++- .../ClubSheetIntegratedServiceTest.java | 88 +++++++++++++++++++ .../club/ClubSheetMigrationApiTest.java | 60 +++++++++++++ 7 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java index e8aaaf2f..9c4bd1d5 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -45,4 +45,17 @@ ResponseEntity importPreMembers( @Valid @RequestBody SheetImportRequest request, @UserId Integer requesterId ); + + @Operation( + summary = "스프레드시트 분석 후 사전 회원 가져오기", + description = "구글 스프레드시트 URL을 받아 먼저 시트를 분석 및 등록한 뒤, " + + "같은 스프레드시트에서 사전 회원을 읽어 DB에 등록합니다. " + + "기존 PUT /clubs/{clubId}/sheet 와 POST /clubs/{clubId}/sheet/import 를 순서대로 실행한 결과와 동일합니다." + ) + @PostMapping("/{clubId}/sheet/import/integrated") + ResponseEntity analyzeAndImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ); } diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java index b2ff2387..002d130b 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -10,6 +10,7 @@ import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; import gg.agit.konect.domain.club.service.SheetImportService; import gg.agit.konect.domain.club.service.SheetMigrationService; import gg.agit.konect.global.auth.annotation.UserId; @@ -23,6 +24,7 @@ public class ClubSheetMigrationController implements ClubSheetMigrationApi { private final SheetMigrationService sheetMigrationService; private final SheetImportService sheetImportService; + private final ClubSheetIntegratedService clubSheetIntegratedService; @Override public ResponseEntity migrateSheet( @@ -47,4 +49,16 @@ public ResponseEntity importPreMembers( ); return ResponseEntity.ok(response); } + + @Override + public ResponseEntity analyzeAndImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ) { + SheetImportResponse response = clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, requesterId, request.spreadsheetUrl() + ); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 222f8d88..b5ccb741 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -43,6 +43,26 @@ public void updateSheetId( SheetHeaderMapper.SheetAnalysisResult result = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + applySheetRegistration(club, spreadsheetId, result); + } + + @Transactional + void updateSheetId( + Integer clubId, + Integer requesterId, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + applySheetRegistration(club, spreadsheetId, result); + } + + private void applySheetRegistration( + Club club, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { String mappingJson = null; try { mappingJson = objectMapper.writeValueAsString(result.memberListMapping().toMap()); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java new file mode 100644 index 00000000..d6b8ac7c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -0,0 +1,41 @@ +package gg.agit.konect.domain.club.service; + +import org.springframework.stereotype.Service; + +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ClubSheetIntegratedService { + + private final ClubPermissionValidator clubPermissionValidator; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubMemberSheetService clubMemberSheetService; + private final SheetImportService sheetImportService; + + public SheetImportResponse analyzeAndImportPreMembers( + Integer clubId, + Integer requesterId, + String spreadsheetUrl + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + SheetHeaderMapper.SheetAnalysisResult analysis = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + + clubMemberSheetService.updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysis + ); + return sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 5d5664ec..d81b4390 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -54,15 +54,37 @@ public SheetImportResponse importPreMembersFromSheet( String spreadsheetUrl ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); - Club club = clubRepository.getById(clubId); - Integer universityId = club.getUniversity().getId(); - String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); - SheetColumnMapping mapping = analysis.memberListMapping(); + return importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + } + + @Transactional + SheetImportResponse importPreMembersFromSheet( + Integer clubId, + Integer requesterId, + String spreadsheetId, + SheetColumnMapping mapping + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = clubRepository.getById(clubId); + return importPreMembersFromSheet(clubId, club, spreadsheetId, mapping); + } + private SheetImportResponse importPreMembersFromSheet( + Integer clubId, + Club club, + String spreadsheetId, + SheetColumnMapping mapping + ) { + Integer universityId = club.getUniversity().getId(); List> rows = readDataRows(spreadsheetId, mapping); // N+1 방지: 루프 전 기존 부원 학번 Set / 사전 회원 key Set / 부원 userId Set 일괄 조회 diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java new file mode 100644 index 00000000..873403e9 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java @@ -0,0 +1,88 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubSheetIntegratedServiceTest extends ServiceTestSupport { + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ClubMemberSheetService clubMemberSheetService; + + @Mock + private SheetImportService sheetImportService; + + @InjectMocks + private ClubSheetIntegratedService clubSheetIntegratedService; + + @Test + @DisplayName("시트 분석 등록 후 사전 회원 가져오기를 순서대로 실행한다") + void analyzeAndImportPreMembersSuccess() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + SheetHeaderMapper.SheetAnalysisResult analysis = + new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + SheetImportResponse expected = SheetImportResponse.of(3, 1, List.of("warn")); + + given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); + given(sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + )) + .willReturn(expected); + + // when + SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + ); + + // then + InOrder inOrder = inOrder( + clubPermissionValidator, + sheetHeaderMapper, + clubMemberSheetService, + sheetImportService + ); + inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); + inOrder.verify(clubMemberSheetService).updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysis + ); + inOrder.verify(sheetImportService).importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + assertThat(actual).isEqualTo(expected); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java new file mode 100644 index 00000000..f906a6e3 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -0,0 +1,60 @@ +package gg.agit.konect.integration.domain.club; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; +import gg.agit.konect.support.IntegrationTestSupport; + +class ClubSheetMigrationApiTest extends IntegrationTestSupport { + + @MockitoBean + private ClubSheetIntegratedService clubSheetIntegratedService; + + private static final Integer CLUB_ID = 1; + private static final Integer REQUESTER_ID = 100; + private static final String SPREADSHEET_URL = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + + @BeforeEach + void setUp() throws Exception { + mockLoginUser(REQUESTER_ID); + } + + @Nested + @DisplayName("POST /clubs/{clubId}/sheet/import/integrated - 시트 통합 가져오기") + class AnalyzeAndImportPreMembers { + + @Test + @DisplayName("시트 분석 등록 후 사전 회원 가져오기 결과를 반환한다") + void analyzeAndImportPreMembersSuccess() throws Exception { + // given + given(clubSheetIntegratedService.analyzeAndImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willReturn(SheetImportResponse.of(2, 1, List.of("전화번호 형식 경고"))); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + // when & then + performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.importedCount").value(2)) + .andExpect(jsonPath("$.autoRegisteredCount").value(1)) + .andExpect(jsonPath("$.warnings[0]").value("전화번호 형식 경고")); + } + } +} From 4170cb10a1ade0db7f0c8787dd76bd3168e2d604 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:43:05 +0900 Subject: [PATCH 44/55] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅방 검색 기능 추가 * feat: 채팅 검색 limit 상한 검증 추가 * refactor: 채팅 검색 조회 트랜잭션 정리 * fix: Google Sheets credentials 경로 검증 추가 * fix: 채팅 검색 트랜잭션 경계 보정 * fix: 채팅 검색 가시성 및 이름 매칭 보정 * test: 채팅 검색 회귀 테스트 보강 * refactor: 채팅 검색 조회 트랜잭션 정리 * refactor: 채팅 검색 direct room 조회 범위 축소 --- .../domain/chat/controller/ChatApi.java | 29 +++ .../chat/controller/ChatController.java | 12 + .../chat/dto/ChatMessageMatchResult.java | 45 ++++ .../chat/dto/ChatMessageMatchesResponse.java | 37 +++ .../chat/dto/ChatRoomMatchesResponse.java | 37 +++ .../domain/chat/dto/ChatSearchResponse.java | 14 ++ .../repository/ChatMessageRepository.java | 27 +++ .../domain/chat/service/ChatService.java | 227 +++++++++++++++--- .../googlesheets/GoogleSheetsConfig.java | 4 + .../integration/domain/chat/ChatApiTest.java | 212 ++++++++++++++++ 10 files changed, 606 insertions(+), 38 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index a6fa42b0..eec5d703 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -19,17 +19,22 @@ import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; @Tag(name = "(Normal) Chat: 채팅", description = "채팅 API") @RequestMapping("/chats") public interface ChatApi { + int MAX_SEARCH_LIMIT = 100; + @Operation(summary = "채팅방을 생성하거나 기존 채팅방을 반환한다.", description = """ ## 설명 - 특정 유저와의 1:1 채팅방을 생성하거나 기존 채팅방을 반환합니다. @@ -79,6 +84,30 @@ ResponseEntity getChatRooms( @UserId Integer userId ); + @Operation(summary = "채팅방 이름과 메시지 내용으로 채팅방을 검색한다.", description = """ + ## 설명 + - 현재 사용자가 접근 가능한 채팅방만 검색합니다. + - 채팅방 이름 매칭 결과와 메시지 내용 매칭 결과를 분리해서 반환합니다. + + ## 로직 + - 1:1 채팅은 상대방 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. + - 그룹 채팅은 동아리 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. + - 메시지 검색 결과는 채팅방별 최신 매칭 메시지 1개만 반환합니다. + - page, limit는 채팅방 이름 검색 결과와 메시지 검색 결과에 각각 동일하게 적용됩니다. + - limit는 최대 100까지 허용됩니다. + """) + @GetMapping("/rooms/search") + ResponseEntity searchChats( + @NotBlank(message = "검색어는 필수입니다.") + @RequestParam(name = "keyword") String keyword, + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @RequestParam(name = "page", defaultValue = "1") Integer page, + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = MAX_SEARCH_LIMIT, message = "페이지 당 항목 수는 100 이하여야 합니다.") + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ); + @Operation(summary = "새 채팅방에 초대할 수 있는 사용자 목록을 조회한다.", description = """ ## 설명 - 현재 사용자가 속해 있는 채팅방들의 멤버를 기반으로 초대 가능 사용자 목록을 조회합니다. diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index 861f8f40..8df3f3a9 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -17,6 +17,7 @@ import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.global.auth.annotation.UserId; @@ -56,6 +57,17 @@ public ResponseEntity getChatRooms( return ResponseEntity.ok(response); } + @Override + public ResponseEntity searchChats( + @RequestParam(name = "keyword") String keyword, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ) { + ChatSearchResponse response = chatService.searchChats(userId, keyword, page, limit); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getInvitableUsers( @RequestParam(name = "query", required = false) String query, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java new file mode 100644 index 00000000..91a56d4a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchResult( + @Schema(description = "채팅방 ID", example = "1", requiredMode = REQUIRED) + Integer roomId, + + @Schema(description = "채팅 타입", example = "DIRECT", requiredMode = REQUIRED) + ChatType chatType, + + @Schema(description = "채팅방 이름", example = "개발팀", requiredMode = REQUIRED) + String roomName, + + @Schema(description = "채팅방 이미지 URL", example = "https://example.com/image.png", requiredMode = NOT_REQUIRED) + String roomImageUrl, + + @Schema(description = "검색에 매칭된 메시지 내용", example = "안녕하세요", requiredMode = REQUIRED) + String matchedMessage, + + @Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + LocalDateTime matchedMessageSentAt +) { + + public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { + return new ChatMessageMatchResult( + room.roomId(), + room.chatType(), + room.roomName(), + room.roomImageUrl(), + message.getContent(), + message.getCreatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java new file mode 100644 index 00000000..78b6360e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchesResponse( + @Schema(description = "조건에 해당하는 메시지 매칭 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 메시지 매칭 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "메시지 내용으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List messages +) { + + public static ChatMessageMatchesResponse from(Page page) { + return new ChatMessageMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java new file mode 100644 index 00000000..3d50631c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatRoomMatchesResponse( + @Schema(description = "조건에 해당하는 채팅방 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 채팅방 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "채팅방 이름으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List rooms +) { + + public static ChatRoomMatchesResponse from(Page page) { + return new ChatRoomMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java new file mode 100644 index 00000000..cff92558 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatSearchResponse( + @Schema(description = "채팅방 이름으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatRoomMatchesResponse roomMatches, + + @Schema(description = "메시지 내용으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatMessageMatchesResponse messageMatches +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index e796654b..82551da8 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -119,4 +119,31 @@ SELECT MAX(m2.id) """) List findLatestMessagesByRoomIds(@Param("roomIds") List roomIds); + @Query( + value = """ + SELECT cm + FROM ChatMessage cm + JOIN FETCH cm.chatRoom cr + WHERE cr.id IN :roomIds + AND LOCATE(LOWER(:keyword), LOWER(cm.content)) > 0 + AND cm.id = ( + SELECT MAX(innerCm.id) + FROM ChatMessage innerCm + WHERE innerCm.chatRoom.id = cr.id + AND LOCATE(LOWER(:keyword), LOWER(innerCm.content)) > 0 + ) + ORDER BY cm.createdAt DESC, cm.id DESC + """ + ) + List searchLatestMatchingMessagesByChatRoomIds( + @Param("roomIds") List roomIds, + @Param("keyword") String keyword + ); + + @Query(""" + SELECT COUNT(m) + FROM ChatMessage m + WHERE m.chatRoom.id = :chatRoomId + """) + long countByChatRoomId(@Param("chatRoomId") Integer chatRoomId); } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index d1f8b1ea..00dfb847 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -8,6 +8,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -25,14 +26,18 @@ import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchResult; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchesResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMatchesResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.dto.UnreadMessageCount; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.enums.ChatType; @@ -155,47 +160,22 @@ public void leaveChatRoom(Integer userId, Integer roomId) { } public ChatRoomsSummaryResponse getChatRooms(Integer userId) { - chatRoomMembershipService.ensureClubRoomMemberships(userId); - - List directRooms = getDirectChatRooms(userId); - List clubRooms = getClubChatRooms(userId); - - List roomIds = new ArrayList<>(); - roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - - Map muteMap = getMuteMap(roomIds, userId); - Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); - List rooms = new ArrayList<>(); - - directRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); - - clubRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); + return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); + } - rooms.sort( - Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(ChatRoomSummaryResponse::roomId) + public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { + String normalizedKeyword = normalizeKeyword(keyword); + AccessibleChatRooms accessibleChatRooms = getAccessibleChatRooms(userId); + ChatRoomMatchesResponse roomMatches = searchRoomsByName(accessibleChatRooms, normalizedKeyword, page, limit); + ChatMessageMatchesResponse messageMatches = searchByMessageContent( + userId, + accessibleChatRooms.rooms(), + normalizedKeyword, + page, + limit ); - return new ChatRoomsSummaryResponse(rooms); + return new ChatSearchResponse(roomMatches, messageMatches); } public ChatInvitableUsersResponse getInvitableUsers( @@ -649,6 +629,123 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ); } + private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { + chatRoomMembershipService.ensureClubRoomMemberships(userId); + + List directRooms = getDirectChatRooms(userId); + List clubRooms = getClubChatRooms(userId); + + List roomIds = new ArrayList<>(); + roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + + Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); + Map defaultRoomNameMap = getDefaultRoomNameMap(directRooms, clubRooms); + List rooms = new ArrayList<>(); + directRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); + clubRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); + + rooms.sort( + Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ChatRoomSummaryResponse::roomId) + ); + return new AccessibleChatRooms(rooms, defaultRoomNameMap); + } + + private ChatRoomSummaryResponse applyRoomSettings( + ChatRoomSummaryResponse room, + Map muteMap, + Map customRoomNameMap + ) { + return new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ); + } + + private ChatRoomMatchesResponse searchRoomsByName( + AccessibleChatRooms accessibleChatRooms, + String keyword, + Integer page, + Integer limit + ) { + List matchedRooms = accessibleChatRooms.rooms().stream() + .filter(room -> matchesRoomName(room, keyword, accessibleChatRooms.defaultRoomNameMap())) + .toList(); + + return ChatRoomMatchesResponse.from(toPage(matchedRooms, page, limit)); + } + + private ChatMessageMatchesResponse searchByMessageContent( + Integer userId, + List accessibleRooms, + String keyword, + Integer page, + Integer limit + ) { + if (accessibleRooms.isEmpty() || keyword.isBlank()) { + return ChatMessageMatchesResponse.from(emptyPage(page, limit)); + } + + Map roomMap = accessibleRooms.stream() + .collect(Collectors.toMap(ChatRoomSummaryResponse::roomId, room -> room)); + List roomIds = accessibleRooms.stream() + .map(ChatRoomSummaryResponse::roomId) + .toList(); + List directRoomIds = accessibleRooms.stream() + .filter(room -> room.chatType() == ChatType.DIRECT) + .map(ChatRoomSummaryResponse::roomId) + .toList(); + Map visibleMessageFromMap = getVisibleMessageFromMap(directRoomIds, userId); + + List matchedMessages = chatMessageRepository + .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword) + .stream() + .filter(message -> isVisibleMessageMatch(message, roomMap, visibleMessageFromMap)) + .map(message -> ChatMessageMatchResult.from(roomMap.get(message.getChatRoom().getId()), message)) + .toList(); + + return ChatMessageMatchesResponse.from(toPage(matchedMessages, page, limit)); + } + + private String normalizeKeyword(String keyword) { + if (keyword == null) { + return ""; + } + return keyword.trim(); + } + + private boolean containsKeyword(String text, String keyword) { + if (text == null || keyword.isBlank()) { + return false; + } + + return text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); + } + + private Page toPage(List items, Integer page, Integer limit) { + PageRequest pageable = PageRequest.of(page - 1, limit); + long offset = (long)(page - 1) * limit; + if (offset >= items.size()) { + return new PageImpl<>(List.of(), pageable, items.size()); + } + + int fromIndex = (int)offset; + int toIndex = Math.min(fromIndex + limit, items.size()); + return new PageImpl<>(items.subList(fromIndex, toIndex), pageable, items.size()); + } + + private Page emptyPage(Integer page, Integer limit) { + return new PageImpl<>(List.of(), PageRequest.of(page - 1, limit), 0); + } + private Map getMuteMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -668,6 +765,28 @@ private Map getMuteMap(List roomIds, Integer userId) return muteMap; } + private Map getVisibleMessageFromMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + Map visibleMessageFromMap = new HashMap<>(); + for (ChatRoomMember roomMember : chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId)) { + visibleMessageFromMap.put(roomMember.getChatRoomId(), roomMember.getVisibleMessageFrom()); + } + return visibleMessageFromMap; + } + + private Map getDefaultRoomNameMap( + List directRooms, + List clubRooms + ) { + Map defaultRoomNameMap = new HashMap<>(); + directRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + clubRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + return defaultRoomNameMap; + } + private Map getCustomRoomNameMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -682,6 +801,38 @@ private String resolveRoomName(Integer roomId, String defaultRoomName, Map defaultRoomNameMap + ) { + if (containsKeyword(room.roomName(), keyword)) { + return true; + } + + return containsKeyword(defaultRoomNameMap.get(room.roomId()), keyword); + } + + private boolean isVisibleMessageMatch( + ChatMessage message, + Map roomMap, + Map visibleMessageFromMap + ) { + ChatRoomSummaryResponse room = roomMap.get(message.getChatRoom().getId()); + if (room == null || room.chatType() != ChatType.DIRECT) { + return true; + } + + LocalDateTime visibleMessageFrom = visibleMessageFromMap.get(room.roomId()); + return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); + } + + private record AccessibleChatRooms( + List rooms, + Map defaultRoomNameMap + ) { + } + private ChatRoom getDirectRoom(Integer roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index b81e50ef..1e4ed426 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -88,6 +88,10 @@ public Drive buildUserDriveService(String refreshToken) throws IOException, Gene private InputStream openCredentialsStream() throws IOException { String credentialsPath = googleSheetsProperties.credentialsPath(); + if (credentialsPath == null || credentialsPath.isBlank()) { + throw new IOException("Google Sheets credentials path is not configured."); + } + if (credentialsPath.startsWith("classpath:")) { Resource resource = resourceLoader.getResource(credentialsPath); if (!resource.exists()) { diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index fb2caea2..e31174f5 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -732,6 +732,195 @@ void getMessagesForbidden() throws Exception { } } + @Nested + @DisplayName("GET /chats/rooms/search - 채팅 검색") + class SearchChats { + + private User secondTargetUser; + private Club developmentClub; + + @BeforeEach + void setUpSearchFixture() { + targetUser = createUser("개발팀", "2021136002"); + secondTargetUser = createUser("개발자", "2021136003"); + outsiderUser = createUser("외부유저", "2021136004"); + developmentClub = persist(ClubFixture.create(university, "개발동아리")); + createClubMember(developmentClub, normalUser); + clearPersistenceContext(); + } + + @Test + @DisplayName("채팅방 이름과 상대방 이름으로 검색 결과를 분리해서 반환한다") + void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + persistChatMessage(directRoom, normalUser, "안녕하세요"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(2)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(1)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.roomMatches.rooms[0].chatType").value("DIRECT")) + .andExpect(jsonPath("$.roomMatches.rooms[1].roomName").value("개발동아리")) + .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("GROUP")) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 접근 가능한 방에서 방별 최신 매칭 메시지 1개만 반환한다") + void searchChatsReturnsLatestMatchingMessagePerAccessibleRoom() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom outsiderRoom = createDirectChatRoom(outsiderUser, targetUser); + + persistChatMessage(firstRoom, normalUser, "첫 번째 키워드"); + persistChatMessage(secondRoom, secondTargetUser, "두 번째 키워드"); + persistChatMessage(outsiderRoom, outsiderUser, "외부 키워드"); + persistChatMessage(firstRoom, targetUser, "최신 키워드"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=키워드&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(2)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(2)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("최신 키워드")) + .andExpect(jsonPath("$.messageMatches.messages[1].roomName").value("개발자")) + .andExpect(jsonPath("$.messageMatches.messages[1].matchedMessage").value("두 번째 키워드")); + } + + @Test + @DisplayName("나간 1:1 채팅방의 숨김 메시지는 검색 결과에 노출되지 않는다") + void searchChatsExcludesHiddenMessagesFromLeftDirectRoom() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("비밀 키워드")) + .andExpect(status().isOk()); + performDelete("/chats/rooms/" + directRoom.getId()) + .andExpect(status().isNoContent()); + + mockLoginUser(targetUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) + .andExpect(status().isOk()); + + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=비밀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 LIKE 특수문자를 리터럴로 처리한다") + void searchChatsTreatsLikeSpecialCharactersAsLiteral() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + + persistChatMessage(firstRoom, normalUser, "100% 완료"); + persistChatMessage(secondRoom, secondTargetUser, "1000 완료"); + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "keyword", List.of("100%"), + "page", List.of("1"), + "limit", List.of("10") + )); + + // when & then + performGet("/chats/rooms/search", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(1)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(1)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("100% 완료")); + } + + @Test + @DisplayName("커스텀 채팅방 이름이 있어도 기본 상대방 이름으로 검색할 수 있다") + void searchChatsMatchesDefaultNameEvenWithCustomRoomName() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + performPatch("/chats/rooms/" + directRoom.getId() + "/name", new ChatRoomNameUpdateRequest("내 메모")) + .andExpect(status().isOk()); + + // when & then + performGet("/chats/rooms/search?keyword=개발팀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(1)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomId").value(directRoom.getId())) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("내 메모")); + } + + @Test + @DisplayName("채팅방 검색 결과에 페이지네이션을 적용한다") + void searchChatsAppliesPaginationToRoomMatches() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2&limit=1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(3)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(3)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발자")); + } + + @Test + @DisplayName("매우 큰 page 값이어도 빈 검색 결과를 안전하게 반환한다") + void searchChatsWithVeryLargePageReturnsEmptyResult() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2147483647&limit=100") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(0)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2147483647)); + } + + @Test + @DisplayName("limit가 최대값을 초과하면 400을 반환한다") + void searchChatsWithTooLargeLimitFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=101") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("searchChats.limit")); + } + } + @Nested @DisplayName("POST /chats/rooms/{chatRoomId}/mute - 채팅방 뮤트 토글") class ToggleMute { @@ -784,6 +973,29 @@ private User createUser(String name, String studentId) { return persist(UserFixture.createUser(university, name, studentId)); } + private ClubMember createClubMember(Club club, User user) { + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + ClubMember clubMember = persist(ClubMember.builder() + .club(managedClub) + .user(managedUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + clearPersistenceContext(); + return clubMember; + } + + private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedSender = entityManager.getReference(User.class, sender.getId()); + + ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); + managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + entityManager.flush(); + clearPersistenceContext(); + return chatMessage; + } + private void addRoomMember(ChatRoom chatRoom, User user) { ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); User managedUser = entityManager.getReference(User.class, user.getId()); From 61cea2862f33db37d5a0d25f8ec9e69a27de4650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:20:41 +0900 Subject: [PATCH 45/55] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅방 멤버에 방장 여부 필드 추가 * feat: 그룹 채팅방 생성 API 추가 * feat: 그룹 채팅방 생성 기능 구현 * feat: 그룹 채팅방 유형 분리 및 관련 로직 확장 * feat: 채팅방 생성 시간 노출 및 정렬 로직 반영 * chore: 코드 포맷팅 * feat: `getById` 메서드 추가 및 기존 로직 리팩토링 * chore: `@PostMapping` 어노테이션 제거 * feat: `saveAll` 메서드 추가 및 채팅방 멤버 저장 로직 최적화 * chore: `DEFAULT_GROUP_ROOM_NAME` 상수 추가 및 관련 코드 정리 * feat: `updateLastReadAt` 로직 개선 및 예외 처리 추가 * chore: 코드 포맷팅 * feat: 그룹 채팅방 멤버 강퇴 기능 추가 (#474) * fix: getGroupChatRooms 중복 커스텀 방 이름 조회 제거 * chore: 채팅방 중복 생성 관련 문서화 주석 제거 * fix: group 메시지 전송 시 강퇴된 멤버 row 재생성 방지, lastReadAt 업데이트 fallback을 club 전용으로 분리 * fix: 나간 그룹 채팅방이 목록에 표시되는 문제 방지를 위해 leftAt 필터 추가 * fix: 일반 GROUP 방에서 getAccessibleRoomMember가 퇴장 여부 확인하도록 수정 * fix: 충돌 해결하면서 발생한 문제 해결 * chore: 코드 포맷팅 --- .../domain/chat/controller/ChatApi.java | 51 ++- .../chat/controller/ChatController.java | 23 +- .../chat/dto/AdminChatRoomProjection.java | 1 + .../chat/dto/ChatRoomCreateRequest.java | 11 +- .../chat/dto/ChatRoomSummaryResponse.java | 4 + .../konect/domain/chat/enums/ChatType.java | 1 + .../konect/domain/chat/model/ChatRoom.java | 8 +- .../domain/chat/model/ChatRoomMember.java | 22 +- .../repository/ChatRoomMemberRepository.java | 2 + .../chat/repository/ChatRoomRepository.java | 24 +- .../service/ChatRoomMembershipService.java | 4 +- .../domain/chat/service/ChatService.java | 334 ++++++++++++++++-- .../domain/club/service/ClubService.java | 2 +- .../konect/global/code/ApiResponseCode.java | 4 + .../V65__add_is_owner_to_chat_room_member.sql | 2 + .../migration/V66__split_group_chat_type.sql | 5 + 16 files changed, 453 insertions(+), 45 deletions(-) create mode 100644 src/main/resources/db/migration/V65__add_is_owner_to_chat_room_member.sql create mode 100644 src/main/resources/db/migration/V66__split_group_chat_type.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index eec5d703..3a3e7426 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -10,15 +10,15 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; -import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; -import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.global.auth.annotation.UserId; @@ -88,7 +88,7 @@ ResponseEntity getChatRooms( ## 설명 - 현재 사용자가 접근 가능한 채팅방만 검색합니다. - 채팅방 이름 매칭 결과와 메시지 내용 매칭 결과를 분리해서 반환합니다. - + ## 로직 - 1:1 채팅은 상대방 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. - 그룹 채팅은 동아리 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. @@ -219,4 +219,47 @@ ResponseEntity leaveChatRoom( @PathVariable(value = "chatRoomId") Integer chatRoomId, @UserId Integer userId ); + + @Operation(summary = "채팅방 멤버를 강퇴한다.", description = """ + ## 설명 + - 그룹 채팅방에서 방장이 특정 멤버를 강퇴합니다. + + ## 로직 + - 방장(owner)만 멤버를 강퇴할 수 있습니다. + - 1:1 채팅방과 동아리 채팅방에서는 강퇴할 수 없습니다. + - 자기 자신(방장)은 강퇴할 수 없습니다. + - 이미 채팅방에 없는 멤버는 강퇴할 수 없습니다. + + ## 에러 + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - FORBIDDEN_CHAT_ROOM_KICK (403): 채팅방 방장만 멤버를 강퇴할 수 있습니다. + - CANNOT_KICK_SELF (400): 자기 자신을 강퇴할 수 없습니다. + - CANNOT_KICK_ROOM_OWNER (400): 방장은 강퇴할 수 없습니다. + - CANNOT_KICK_IN_NON_GROUP_ROOM (400): 그룹 채팅방에서만 강퇴할 수 있습니다. + """) + @DeleteMapping("/rooms/{chatRoomId}/members/{targetUserId}") + ResponseEntity kickMember( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @PathVariable(value = "targetUserId") Integer targetUserId, + @UserId Integer userId + ); + + @Operation(summary = "그룹 채팅방을 생성한다.", description = """ + ## 설명 + - 여러 유저를 초대하여 그룹 채팅방을 생성합니다. + + ## 로직 + - 요청자(방장)를 포함하여 선택된 모든 유저가 참여하는 그룹 채팅방을 생성합니다. + - 방장은 채팅방을 생성한 사용자입니다. + + ## 에러 + - CANNOT_CREATE_CHAT_ROOM_WITH_SELF (400): 자기 자신만으로는 채팅방을 만들 수 없습니다. + - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. + """) + @PostMapping("/rooms/group") + ResponseEntity createGroupChatRoom( + @Valid @RequestBody ChatRoomCreateRequest.Group request, + @UserId Integer userId + ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index 8df3f3a9..4fe35f06 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -8,10 +8,10 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; -import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; @@ -127,4 +127,23 @@ public ResponseEntity leaveChatRoom( chatService.leaveChatRoom(userId, chatRoomId); return ResponseEntity.noContent().build(); } + + @Override + public ResponseEntity kickMember( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @PathVariable(value = "targetUserId") Integer targetUserId, + @UserId Integer userId + ) { + chatService.kickMember(userId, chatRoomId, targetUserId); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity createGroupChatRoom( + @Valid @RequestBody ChatRoomCreateRequest.Group request, + @UserId Integer userId + ) { + ChatRoomResponse response = chatService.createGroupChatRoom(userId, request); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java index d6591396..a277a19a 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java @@ -10,6 +10,7 @@ public record AdminChatRoomProjection( Integer roomId, String lastMessage, LocalDateTime lastSentAt, + LocalDateTime createdAt, Integer nonAdminUserId, String nonAdminUserName, String nonAdminImageUrl, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java index e4b75b9c..114a257b 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java @@ -2,13 +2,22 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import java.util.List; + import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; public record ChatRoomCreateRequest( @NotNull(message = "유저 ID는 필수입니다.") - @Schema(description = "채팅 대상 유저 ID", example = "10", requiredMode = REQUIRED) + @Schema(description = "채팅 대상 유저 ID (1:1 채팅 시)", example = "10", requiredMode = REQUIRED) Integer userId ) { + public record Group( + @NotEmpty(message = "초대할 유저 ID 목록은 필수입니다.") + @Schema(description = "초대할 유저 ID 목록", example = "[10, 11, 12]", requiredMode = REQUIRED) + List userIds + ) { + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java index 0688a20c..ea4a7b5b 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java @@ -30,6 +30,10 @@ public record ChatRoomSummaryResponse( @JsonFormat(pattern = "yyyy.MM.dd HH:mm") LocalDateTime lastSentAt, + @Schema(description = "채팅방 생성 시간", example = "2025.12.19 23:20", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + LocalDateTime createdAt, + @Schema(description = "읽지 않은 메시지 수", example = "12", requiredMode = REQUIRED) Integer unreadCount, diff --git a/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java b/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java index 10b39877..24f08434 100644 --- a/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java +++ b/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java @@ -2,5 +2,6 @@ public enum ChatType { DIRECT, + CLUB_GROUP, GROUP } diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java index 6e5a8faf..6152f0f0 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java @@ -63,9 +63,9 @@ public static ChatRoom directOf() { .build(); } - public static ChatRoom groupOf(Club club) { + public static ChatRoom clubGroupOf(Club club) { return ChatRoom.builder() - .roomType(ChatType.GROUP) + .roomType(ChatType.CLUB_GROUP) .club(club) .build(); } @@ -87,11 +87,11 @@ public boolean isDirectRoom() { } public boolean isGroupRoom() { - return roomType == ChatType.GROUP; + return roomType == ChatType.GROUP || roomType == ChatType.CLUB_GROUP; } public boolean isClubGroupRoom() { - return roomType == ChatType.GROUP && club != null; + return roomType == ChatType.CLUB_GROUP; } public void updateLastMessage(String lastMessageContent, LocalDateTime lastMessageSentAt) { diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index 79dd6cde..7e9b39ff 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -49,6 +49,9 @@ public class ChatRoomMember extends BaseEntity { @Column(name = "custom_room_name", length = 30) private String customRoomName; + @Column(name = "is_owner", nullable = false) + private Boolean isOwner; + @Builder private ChatRoomMember( ChatRoomMemberId id, @@ -57,7 +60,8 @@ private ChatRoomMember( LocalDateTime lastReadAt, LocalDateTime visibleMessageFrom, LocalDateTime leftAt, - String customRoomName + String customRoomName, + Boolean isOwner ) { this.id = id; this.chatRoom = chatRoom; @@ -66,6 +70,7 @@ private ChatRoomMember( this.visibleMessageFrom = visibleMessageFrom; this.leftAt = leftAt; this.customRoomName = customRoomName; + this.isOwner = isOwner != null ? isOwner : false; } public static ChatRoomMember of(ChatRoom chatRoom, User user, LocalDateTime lastReadAt) { @@ -74,6 +79,17 @@ public static ChatRoomMember of(ChatRoom chatRoom, User user, LocalDateTime last .chatRoom(chatRoom) .user(user) .lastReadAt(lastReadAt) + .isOwner(false) + .build(); + } + + public static ChatRoomMember ofOwner(ChatRoom chatRoom, User user, LocalDateTime lastReadAt) { + return ChatRoomMember.builder() + .id(new ChatRoomMemberId(chatRoom.getId(), user.getId())) + .chatRoom(chatRoom) + .user(user) + .lastReadAt(lastReadAt) + .isOwner(true) .build(); } @@ -103,6 +119,10 @@ public boolean hasLeft() { return leftAt != null; } + public boolean isOwner() { + return isOwner != null && isOwner; + } + public void leaveDirectRoom(LocalDateTime leftAt) { this.leftAt = leftAt; this.visibleMessageFrom = leftAt; diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 9707f1a4..41e18c08 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -119,4 +119,6 @@ List countUnreadByRoomIdsAndUserId( @Param("roomIds") List roomIds, @Param("userId") Integer userId ); + + void saveAll(List members); } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index bc96ac82..7d6ec636 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -1,5 +1,7 @@ package gg.agit.konect.domain.chat.repository; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; + import java.util.List; import java.util.Optional; @@ -11,6 +13,7 @@ import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.exception.CustomException; public interface ChatRoomRepository extends Repository { @@ -27,6 +30,17 @@ public interface ChatRoomRepository extends Repository { """) List findByUserId(@Param("userId") Integer userId, @Param("roomType") ChatType roomType); + @Query(""" + SELECT DISTINCT cr + FROM ChatRoom cr + JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id + WHERE crm.id.userId = :userId + AND cr.roomType = gg.agit.konect.domain.chat.enums.ChatType.GROUP + AND crm.leftAt IS NULL + ORDER BY COALESCE(cr.lastMessageSentAt, cr.createdAt) DESC + """) + List findGroupRoomsByMemberUserId(@Param("userId") Integer userId); + @Query(""" SELECT cr FROM ChatRoom cr @@ -35,6 +49,11 @@ public interface ChatRoomRepository extends Repository { """) Optional findById(@Param("chatRoomId") Integer chatRoomId); + default ChatRoom getById(Integer chatRoomId) { + return findById(chatRoomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + } + @Query(""" SELECT cr FROM ChatRoom cr @@ -142,6 +161,7 @@ List findAllSystemAdminDirectRooms( cr.id, cr.lastMessageContent, cr.lastMessageSentAt, + cr.createdAt, u.id, u.name, u.imageUrl, @@ -163,8 +183,8 @@ AND EXISTS ( WHERE userReply.chatRoom.id = cr.id AND userSender.role != :adminRole ) - GROUP BY cr.id, cr.lastMessageContent, cr.lastMessageSentAt, u.id, u.name, u.imageUrl - ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id + GROUP BY cr.id, cr.lastMessageContent, cr.lastMessageSentAt, cr.createdAt, u.id, u.name, u.imageUrl + ORDER BY COALESCE(cr.lastMessageSentAt, cr.createdAt) DESC """) List findAdminChatRoomsOptimized( @Param("systemAdminId") Integer systemAdminId, diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index fb3f3185..3f285189 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -154,7 +154,7 @@ private ChatRoom findOrCreateClubRoom(Club club) { return chatRoomRepository.findByClubId(club.getId()) .orElseGet(() -> { try { - return chatRoomRepository.save(ChatRoom.groupOf(club)); + return chatRoomRepository.save(ChatRoom.clubGroupOf(club)); } catch (DataIntegrityViolationException e) { if (!isDuplicateKeyException(e)) { throw e; @@ -181,7 +181,7 @@ private List resolveOrCreateClubRooms(List memberships) { continue; } try { - ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.groupOf(clubEntry.getValue())); + ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.clubGroupOf(clubEntry.getValue())); roomByClubId.put(clubEntry.getKey(), createdRoom); } catch (DataIntegrityViolationException e) { if (!isDuplicateKeyException(e)) { diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 00dfb847..9b04c825 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -72,6 +72,7 @@ public class ChatService { private static final int SYSTEM_ADMIN_ID = 1; private static final String ETC_SECTION_NAME = "기타"; + private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; @@ -141,6 +142,37 @@ public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { return createOrGetChatRoom(currentUserId, new ChatRoomCreateRequest(adminUser.getId())); } + @Transactional + public ChatRoomResponse createGroupChatRoom(Integer currentUserId, ChatRoomCreateRequest.Group request) { + User creator = userRepository.getById(currentUserId); + + List distinctUserIds = request.userIds().stream() + .distinct() + .filter(id -> !id.equals(currentUserId)) + .toList(); + + if (distinctUserIds.isEmpty()) { + throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); + } + + List invitees = userRepository.findAllByIdIn(distinctUserIds); + if (invitees.size() != distinctUserIds.size()) { + throw CustomException.of(NOT_FOUND_USER); + } + + ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.groupOf()); + LocalDateTime joinedAt = Objects.requireNonNull( + chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" + ); + + List members = new ArrayList<>(); + members.add(ChatRoomMember.ofOwner(chatRoom, creator, joinedAt)); + invitees.forEach(user -> members.add(ChatRoomMember.of(chatRoom, user, joinedAt))); + chatRoomMemberRepository.saveAll(members); + + return ChatRoomResponse.from(chatRoom); + } + @Transactional public void leaveChatRoom(Integer userId, Integer roomId) { ChatRoom room = chatRoomRepository.findById(roomId) @@ -159,7 +191,83 @@ public void leaveChatRoom(Integer userId, Integer roomId) { chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, userId); } + @Transactional + public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + validateGroupRoomForKick(room); + validateNotSelfKick(requesterId, targetUserId); + + ChatRoomMember requester = getRoomMember(roomId, requesterId); + validateKickAuthority(requester); + + ChatRoomMember target = getRoomMember(roomId, targetUserId); + validateNotOwnerTarget(target); + + chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, targetUserId); + } + public ChatRoomsSummaryResponse getChatRooms(Integer userId) { + chatRoomMembershipService.ensureClubRoomMemberships(userId); + + List directRooms = getDirectChatRooms(userId); + List clubRooms = getClubChatRooms(userId); + List groupRooms = getGroupChatRooms(userId); + + List roomIds = new ArrayList<>(); + roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + roomIds.addAll(groupRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + + Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); + List rooms = new ArrayList<>(); + + directRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.createdAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ))); + + clubRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.createdAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ))); + + groupRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.createdAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ))); + + rooms.sort( + Comparator.comparing( + (ChatRoomSummaryResponse room) -> + room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), + Comparator.reverseOrder() + ) + ); + return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); } @@ -295,10 +403,17 @@ public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integ return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); } - chatRoomMembershipService.ensureClubRoomMember(roomId, userId); + if (room.isClubGroupRoom()) { + chatRoomMembershipService.ensureClubRoomMember(roomId, userId); + chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); + recordPresenceSafely(roomId, userId); + return getClubMessagesByRoomId(roomId, userId, page, limit); + } + + getAccessibleRoomMember(room, userId); chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); recordPresenceSafely(roomId, userId); - return getClubMessagesByRoomId(roomId, userId, page, limit); + return getGroupMessagesByRoomId(roomId, userId, page, limit); } @Transactional @@ -310,7 +425,11 @@ public ChatMessageDetailResponse sendMessage(Integer userId, Integer roomId, Cha return sendDirectMessage(userId, roomId, request); } - return sendClubMessageByRoomId(roomId, userId, request.content()); + if (room.isClubGroupRoom()) { + return sendClubMessageByRoomId(roomId, userId, request.content()); + } + + return sendGroupMessageByRoomId(roomId, userId, request.content()); } @Transactional @@ -319,11 +438,13 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); User user = userRepository.getById(userId); - if (room.isGroupRoom()) { + if (room.isClubGroupRoom()) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); - } else { + } else if (room.isDirectRoom()) { getAccessibleDirectRoomMember(room, user); + } else { + getAccessibleRoomMember(room, userId); } Boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( NotificationTargetType.CHAT_ROOM, @@ -388,6 +509,7 @@ private List getDirectChatRooms(Integer userId) { chatPartner.getImageUrl(), getVisibleLastMessageContent(chatRoom, currentMember), getVisibleLastMessageSentAt(chatRoom, currentMember), + chatRoom.getCreatedAt(), personalUnreadCountMap.getOrDefault(chatRoom.getId(), 0), false )); @@ -395,10 +517,10 @@ private List getDirectChatRooms(Integer userId) { roomSummaries.sort(Comparator .comparing( - ChatRoomSummaryResponse::lastSentAt, - Comparator.nullsLast(Comparator.reverseOrder()) - ) - .thenComparing(ChatRoomSummaryResponse::roomId)); + (ChatRoomSummaryResponse room) -> + room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), + Comparator.reverseOrder() + )); return roomSummaries; } @@ -416,6 +538,7 @@ private List getAdminDirectChatRooms() { projection.nonAdminImageUrl(), projection.lastMessage(), projection.lastSentAt(), + projection.createdAt(), projection.unreadCount().intValue(), false )) @@ -446,11 +569,40 @@ private List getClubChatRooms(Integer userId) { ChatMessage lastMessage = lastMessageMap.get(room.getId()); return new ChatRoomSummaryResponse( room.getId(), - ChatType.GROUP, + ChatType.CLUB_GROUP, room.getClub().getName(), room.getClub().getImageUrl(), lastMessage != null ? lastMessage.getContent() : null, lastMessage != null ? lastMessage.getCreatedAt() : null, + room.getCreatedAt(), + unreadCountMap.getOrDefault(room.getId(), 0), + false + ); + }) + .toList(); + } + + private List getGroupChatRooms(Integer userId) { + List rooms = chatRoomRepository.findGroupRoomsByMemberUserId(userId); + if (rooms.isEmpty()) { + return List.of(); + } + + List roomIds = rooms.stream().map(ChatRoom::getId).toList(); + Map lastMessageMap = getLastMessageMap(roomIds); + Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); + + return rooms.stream() + .map(room -> { + ChatMessage lastMessage = lastMessageMap.get(room.getId()); + return new ChatRoomSummaryResponse( + room.getId(), + ChatType.GROUP, + DEFAULT_GROUP_ROOM_NAME, + null, + lastMessage != null ? lastMessage.getContent() : null, + lastMessage != null ? lastMessage.getCreatedAt() : null, + room.getCreatedAt(), unreadCountMap.getOrDefault(room.getId(), 0), false ); @@ -602,7 +754,7 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); room.updateLastMessage(message.getContent(), message.getCreatedAt()); - updateLastReadAt(roomId, userId, message.getCreatedAt()); + updateClubMessageLastReadAt(roomId, userId, message.getCreatedAt()); List members = chatRoomMemberRepository.findByChatRoomId(roomId); List recipientUserIds = members.stream().map(ChatRoomMember::getUserId).toList(); @@ -629,6 +781,90 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ); } + private ChatMessagePageResponse getGroupMessagesByRoomId( + Integer roomId, + Integer userId, + Integer page, + Integer limit + ) { + chatRoomRepository.getById(roomId); + + PageRequest pageable = PageRequest.of(page - 1, limit); + long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); + Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); + List messages = messagePage.getContent(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List sortedReadBaselines = toSortedReadBaselines(members); + + List responseMessages = messages.stream() + .map(message -> { + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + message.getSender().getId(), + message.getSender().getName(), + message.getContent(), + message.getCreatedAt(), + null, + unreadCount, + message.isSentBy(userId) + ); + }) + .toList(); + + int totalPage = limit > 0 ? (int)Math.ceil((double)totalCount / (double)limit) : 0; + return new ChatMessagePageResponse( + totalCount, + responseMessages.size(), + totalPage, + page, + null, + responseMessages + ); + } + + private ChatMessageDetailResponse sendGroupMessageByRoomId(Integer roomId, Integer userId, String content) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User sender = userRepository.getById(userId); + + ChatRoomMember senderMember = getRoomMember(roomId, userId); + if (senderMember.hasLeft()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); + room.updateLastMessage(message.getContent(), message.getCreatedAt()); + updateLastReadAt(roomId, userId, message.getCreatedAt()); + + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List recipientUserIds = members.stream() + .map(ChatRoomMember::getUserId) + .filter(id -> !id.equals(userId)) + .toList(); + List sortedReadBaselines = toSortedReadBaselines(members); + + notificationService.sendGroupChatNotification( + roomId, + sender.getId(), + DEFAULT_GROUP_ROOM_NAME, + sender.getName(), + message.getContent(), + recipientUserIds + ); + + return new ChatMessageDetailResponse( + message.getId(), + sender.getId(), + sender.getName(), + message.getContent(), + message.getCreatedAt(), + null, + countUnreadSince(message.getCreatedAt(), sortedReadBaselines), + true + ); + } + private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { chatRoomMembershipService.ensureClubRoomMemberships(userId); @@ -647,7 +883,8 @@ private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { clubRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); rooms.sort( - Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) + Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, + Comparator.nullsLast(Comparator.reverseOrder())) .thenComparing(ChatRoomSummaryResponse::roomId) ); return new AccessibleChatRooms(rooms, defaultRoomNameMap); @@ -665,6 +902,7 @@ private ChatRoomSummaryResponse applyRoomSettings( room.roomImageUrl(), room.lastMessage(), room.lastSentAt(), + room.createdAt(), room.unreadCount(), muteMap.getOrDefault(room.roomId(), false) ); @@ -797,7 +1035,8 @@ private Map getCustomRoomNameMap(List roomIds, Integer .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, ChatRoomMember::getCustomRoomName)); } - private String resolveRoomName(Integer roomId, String defaultRoomName, Map customRoomNameMap) { + private String resolveRoomName(Integer roomId, String + defaultRoomName, Map customRoomNameMap) { return customRoomNameMap.getOrDefault(roomId, defaultRoomName); } @@ -827,12 +1066,6 @@ private boolean isVisibleMessageMatch( return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); } - private record AccessibleChatRooms( - List rooms, - Map defaultRoomNameMap - ) { - } - private ChatRoom getDirectRoom(Integer roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); @@ -910,14 +1143,24 @@ private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { } private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { - if (room.isGroupRoom()) { + if (room.isClubGroupRoom()) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); return getRoomMember(room.getId(), userId); } - User user = userRepository.getById(userId); - return getAccessibleDirectRoomMember(room, user); + if (room.isDirectRoom()) { + User user = userRepository.getById(userId); + return getAccessibleDirectRoomMember(room, user); + } + + ChatRoomMember member = getRoomMember(room.getId(), userId); + + if (member.hasLeft()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + return member; } private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { @@ -964,11 +1207,16 @@ private void updateMemberLastReadAt(Integer roomId, Integer userId, LocalDateTim } private void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); + } + + private void updateClubMessageLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); if (updated == 0) { - ChatRoom room = getClubRoom(roomId); - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User user = userRepository.getById(userId); + ensureRoomMember(room, user, lastReadAt); } } @@ -1159,9 +1407,6 @@ private Map> getRoomMembersMap(List room .collect(Collectors.groupingBy(ChatRoomMember::getChatRoomId)); } - private record MemberInfo(Integer userId, LocalDateTime createdAt) { - } - private Map> getRoomMemberInfoMap(List rooms) { if (rooms.isEmpty()) { return Map.of(); @@ -1261,6 +1506,30 @@ private User resolveMessageReceiverFromMemberInfo( return partner; } + private void validateGroupRoomForKick(ChatRoom room) { + if (!room.isGroupRoom() || room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_KICK_IN_NON_GROUP_ROOM); + } + } + + private void validateNotSelfKick(Integer requesterId, Integer targetUserId) { + if (requesterId.equals(targetUserId)) { + throw CustomException.of(CANNOT_KICK_SELF); + } + } + + private void validateKickAuthority(ChatRoomMember requester) { + if (!requester.isOwner()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_KICK); + } + } + + private void validateNotOwnerTarget(ChatRoomMember target) { + if (target.isOwner()) { + throw CustomException.of(CANNOT_KICK_ROOM_OWNER); + } + } + private void recordPresenceSafely(Integer roomId, Integer userId) { try { chatPresenceService.recordPresence(roomId, userId); @@ -1268,4 +1537,13 @@ private void recordPresenceSafely(Integer roomId, Integer userId) { log.warn("Redis presence record failed, continuing: roomId={}, userId={}", roomId, userId, e); } } + + private record AccessibleChatRooms( + List rooms, + Map defaultRoomNameMap + ) { + } + + private record MemberInfo(Integer userId, LocalDateTime createdAt) { + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java index a6a1fa78..08c4dccc 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java @@ -118,7 +118,7 @@ public ClubDetailResponse createClub(Integer userId, ClubCreateRequest request) Club savedClub = clubRepository.save(club); - chatRoomRepository.save(ChatRoom.groupOf(savedClub)); + chatRoomRepository.save(ChatRoom.clubGroupOf(savedClub)); ClubMember president = ClubMember.builder() .club(savedClub) diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index a39b6598..c28c6164 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -20,6 +20,9 @@ public enum ApiResponseCode { FAILED_EXTRACT_PROVIDER_ID(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 제공자 식별자를 가져올 수 없습니다."), CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), CANNOT_LEAVE_GROUP_CHAT_ROOM(HttpStatus.BAD_REQUEST, "동아리 채팅방은 나갈 수 없습니다."), + CANNOT_KICK_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 강퇴할 수 없습니다."), + CANNOT_KICK_ROOM_OWNER(HttpStatus.BAD_REQUEST, "방장은 강퇴할 수 없습니다."), + CANNOT_KICK_IN_NON_GROUP_ROOM(HttpStatus.BAD_REQUEST, "그룹 채팅방에서만 강퇴할 수 있습니다."), INVALID_CHAT_ROOM_CREATE_REQUEST(HttpStatus.BAD_REQUEST, "clubId 또는 targetUserId 중 하나만 전달해야 합니다."), CANNOT_CHANGE_OWN_POSITION(HttpStatus.BAD_REQUEST, "자기 자신의 직책은 변경할 수 없습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), @@ -69,6 +72,7 @@ public enum ApiResponseCode { FORBIDDEN_POSITION_NAME_CHANGE(HttpStatus.FORBIDDEN, "해당 직책의 이름은 변경할 수 없습니다."), FORBIDDEN_ROLE_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), FORBIDDEN_ORIGIN_ACCESS(HttpStatus.FORBIDDEN, "허용되지 않은 Origin 입니다."), + FORBIDDEN_CHAT_ROOM_KICK(HttpStatus.FORBIDDEN, "채팅방 방장만 멤버를 강퇴할 수 있습니다."), // 404 Not Found (리소스를 찾을 수 없음) NO_HANDLER_FOUND(HttpStatus.NOT_FOUND, "유효하지 않은 API 경로입니다."), diff --git a/src/main/resources/db/migration/V65__add_is_owner_to_chat_room_member.sql b/src/main/resources/db/migration/V65__add_is_owner_to_chat_room_member.sql new file mode 100644 index 00000000..1005d0d6 --- /dev/null +++ b/src/main/resources/db/migration/V65__add_is_owner_to_chat_room_member.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_room_member + ADD COLUMN is_owner BOOLEAN NOT NULL DEFAULT FALSE AFTER custom_room_name; diff --git a/src/main/resources/db/migration/V66__split_group_chat_type.sql b/src/main/resources/db/migration/V66__split_group_chat_type.sql new file mode 100644 index 00000000..1d17037e --- /dev/null +++ b/src/main/resources/db/migration/V66__split_group_chat_type.sql @@ -0,0 +1,5 @@ +-- 동아리가 연결된 기존 GROUP 채팅방을 CLUB_GROUP으로 마이그레이션 +UPDATE chat_room +SET room_type = 'CLUB_GROUP' +WHERE room_type = 'GROUP' + AND club_id IS NOT NULL; From 72584a178549d6c41d7c1d50943f2558df48a955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:35:52 +0900 Subject: [PATCH 46/55] =?UTF-8?q?fix:=20saveAll=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=B6=80=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/repository/ChatRoomMemberRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 41e18c08..1b725264 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -120,5 +120,6 @@ List countUnreadByRoomIdsAndUserId( @Param("userId") Integer userId ); - void saveAll(List members); + List saveAll(Iterable chatRoomMembers); + } From 6a55772d1e985d3165d58de7356c6c63d1ea3d1a Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:17:13 +0900 Subject: [PATCH 47/55] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=9D=B8=EC=95=B1=20=EC=95=8C=EB=A6=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 채팅 관련 인앱 알림 제외 * test: 채팅 인앱 알림 제외 검증 보강 * style: NotificationInboxService 줄바꿈 정리 --- .../enums/NotificationInboxType.java | 8 +- .../NotificationInboxRepository.java | 23 +++++- .../service/NotificationInboxService.java | 16 +++- .../service/NotificationService.java | 20 ----- .../NotificationInboxApiTest.java | 82 +++++++++++++------ 5 files changed, 97 insertions(+), 52 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java index e6b4b171..563020a4 100644 --- a/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java +++ b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java @@ -6,5 +6,11 @@ public enum NotificationInboxType { CLUB_APPLICATION_REJECTED, CHAT_MESSAGE, GROUP_CHAT_MESSAGE, - UNREAD_CHAT_COUNT + UNREAD_CHAT_COUNT; + + public boolean isChatRelated() { + return this == CHAT_MESSAGE + || this == GROUP_CHAT_MESSAGE + || this == UNREAD_CHAT_COUNT; + } } diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java index 5323e5f4..e00c6eb8 100644 --- a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.notification.repository; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -10,6 +11,7 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; import gg.agit.konect.domain.notification.model.NotificationInbox; import gg.agit.konect.global.exception.CustomException; @@ -21,15 +23,30 @@ public interface NotificationInboxRepository extends Repository saveAll(Iterable notificationInboxes); - Page findAllByUserIdOrderByCreatedAtDescIdDesc(Integer userId, Pageable pageable); + Page findAllByUserIdAndTypeNotInOrderByCreatedAtDescIdDesc( + Integer userId, + Collection excludedTypes, + Pageable pageable + ); long countByUserIdAndIsReadFalse(Integer userId); + long countByUserIdAndIsReadFalseAndTypeNotIn(Integer userId, Collection excludedTypes); + Optional findByIdAndUserId(Integer id, Integer userId); @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query("UPDATE NotificationInbox n SET n.isRead = true WHERE n.user.id = :userId AND n.isRead = false") - void markAllAsReadByUserId(@Param("userId") Integer userId); + @Query(""" + UPDATE NotificationInbox n + SET n.isRead = true + WHERE n.user.id = :userId + AND n.isRead = false + AND n.type NOT IN :excludedTypes + """) + void markAllAsReadByUserIdAndTypeNotIn( + @Param("userId") Integer userId, + @Param("excludedTypes") Collection excludedTypes + ); default NotificationInbox getByIdAndUserId(Integer id, Integer userId) { return findByIdAndUserId(id, userId) diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java index 6bf3d071..e617d65e 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java @@ -1,6 +1,9 @@ package gg.agit.konect.domain.notification.service; +import java.util.Arrays; +import java.util.EnumSet; import java.util.List; +import java.util.Set; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -25,6 +28,10 @@ public class NotificationInboxService { private static final int DEFAULT_PAGE_SIZE = 20; + private static final Set CHAT_NOTIFICATION_TYPES = + Arrays.stream(NotificationInboxType.values()) + .filter(NotificationInboxType::isChatRelated) + .collect(() -> EnumSet.noneOf(NotificationInboxType.class), Set::add, Set::addAll); private final NotificationInboxRepository notificationInboxRepository; private final UserRepository userRepository; @@ -73,12 +80,15 @@ public void sendSseBatch(List inboxes) { public NotificationInboxesResponse getMyInboxes(Integer userId, int page) { PageRequest pageable = PageRequest.of(page - 1, DEFAULT_PAGE_SIZE); Page result = notificationInboxRepository - .findAllByUserIdOrderByCreatedAtDescIdDesc(userId, pageable); + .findAllByUserIdAndTypeNotInOrderByCreatedAtDescIdDesc(userId, CHAT_NOTIFICATION_TYPES, pageable); return NotificationInboxesResponse.from(result); } public NotificationInboxUnreadCountResponse getUnreadCount(Integer userId) { - long count = notificationInboxRepository.countByUserIdAndIsReadFalse(userId); + long count = notificationInboxRepository.countByUserIdAndIsReadFalseAndTypeNotIn( + userId, + CHAT_NOTIFICATION_TYPES + ); return NotificationInboxUnreadCountResponse.of(count); } @@ -90,6 +100,6 @@ public void markAsRead(Integer userId, Integer notificationId) { @Transactional public void markAllAsRead(Integer userId) { - notificationInboxRepository.markAllAsReadByUserId(userId); + notificationInboxRepository.markAllAsReadByUserIdAndTypeNotIn(userId, CHAT_NOTIFICATION_TYPES); } } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index c09cdf49..15bd7ae8 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -98,16 +98,6 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send String truncatedBody = buildPreview(messageContent); String path = "chats/" + roomId; - NotificationInbox saved = notificationInboxService.save( - receiverId, - NotificationInboxType.CHAT_MESSAGE, - senderName, - truncatedBody, - path - ); - - notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); - List tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId); if (tokens.isEmpty()) { log.debug("No device tokens found for user: receiverId={}", receiverId); @@ -172,16 +162,6 @@ public void sendGroupChatNotification( String previewBody = senderName + ": " + truncatedBody; String path = "chats/" + roomId; - List savedInboxes = notificationInboxService.saveAll( - targetRecipients, - NotificationInboxType.GROUP_CHAT_MESSAGE, - clubName, - previewBody, - path - ); - - notificationInboxService.sendSseBatch(savedInboxes); - List tokens = notificationDeviceTokenRepository.findTokensByUserIds(targetRecipients); Map data = new HashMap<>(); diff --git a/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java index c0ef533b..97a6d6d5 100644 --- a/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java @@ -47,13 +47,11 @@ class GetMyInboxes { @Test @DisplayName("알림 목록을 최신순으로 조회한다") void getMyInboxesSuccess() throws Exception { - // given NotificationInbox first = createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "동아리 승인"); NotificationInbox second = createInbox(user, NotificationInboxType.CLUB_APPLICATION_REJECTED, "동아리 거절"); clearPersistenceContext(); mockLoginUser(user.getId()); - // when & then performGet("/notifications/inbox") .andExpect(status().isOk()) .andExpect(jsonPath("$.notifications").isArray()) @@ -66,26 +64,39 @@ void getMyInboxesSuccess() throws Exception { @Test @DisplayName("자신의 알림만 조회된다") void getMyInboxesOnlyMine() throws Exception { - // given createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "내 알림"); createInbox(otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); clearPersistenceContext(); mockLoginUser(user.getId()); - // when & then performGet("/notifications/inbox") .andExpect(status().isOk()) .andExpect(jsonPath("$.notifications.length()").value(1)) .andExpect(jsonPath("$.notifications[0].title").value("내 알림")); } + @Test + @DisplayName("채팅 관련 인앱 알림은 목록에서 제외된다") + void getMyInboxesExcludesChatNotifications() throws Exception { + createInbox(user, NotificationInboxType.CHAT_MESSAGE, "개인 채팅 알림"); + createInbox(user, NotificationInboxType.GROUP_CHAT_MESSAGE, "그룹 채팅 알림"); + createInbox(user, NotificationInboxType.UNREAD_CHAT_COUNT, "안 읽은 채팅 개수 알림"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "동아리 승인 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications.length()").value(1)) + .andExpect(jsonPath("$.notifications[0].type").value("CLUB_APPLICATION_APPROVED")) + .andExpect(jsonPath("$.notifications[0].title").value("동아리 승인 알림")); + } + @Test @DisplayName("page=0으로 요청하면 400을 반환한다") void getMyInboxesWithInvalidPageFails() throws Exception { - // given mockLoginUser(user.getId()); - // when & then performGet("/notifications/inbox?page=0") .andExpect(status().isBadRequest()); } @@ -93,10 +104,8 @@ void getMyInboxesWithInvalidPageFails() throws Exception { @Test @DisplayName("알림이 없으면 빈 목록을 반환한다") void getMyInboxesWhenEmptyReturnsEmptyList() throws Exception { - // given mockLoginUser(user.getId()); - // when & then performGet("/notifications/inbox") .andExpect(status().isOk()) .andExpect(jsonPath("$.notifications").isEmpty()) @@ -111,13 +120,11 @@ class GetUnreadCount { @Test @DisplayName("미읽음 알림 개수를 반환한다") void getUnreadCountSuccess() throws Exception { - // given createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림2"); clearPersistenceContext(); mockLoginUser(user.getId()); - // when & then performGet("/notifications/inbox/unread-count") .andExpect(status().isOk()) .andExpect(jsonPath("$.unreadCount").value(2)); @@ -126,14 +133,26 @@ void getUnreadCountSuccess() throws Exception { @Test @DisplayName("알림이 없으면 미읽음 개수가 0이다") void getUnreadCountWhenNoneReturnsZero() throws Exception { - // given mockLoginUser(user.getId()); - // when & then performGet("/notifications/inbox/unread-count") .andExpect(status().isOk()) .andExpect(jsonPath("$.unreadCount").value(0)); } + + @Test + @DisplayName("채팅 관련 인앱 알림은 미읽음 개수에서 제외된다") + void getUnreadCountExcludesChatNotifications() throws Exception { + createInbox(user, NotificationInboxType.CHAT_MESSAGE, "개인 채팅 알림"); + createInbox(user, NotificationInboxType.GROUP_CHAT_MESSAGE, "그룹 채팅 알림"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "동아리 승인 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(1)); + } } @Nested @@ -143,12 +162,10 @@ class MarkAsRead { @Test @DisplayName("알림을 읽음 처리한다") void markAsReadSuccess() throws Exception { - // given NotificationInbox inbox = createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "읽을 알림"); clearPersistenceContext(); mockLoginUser(user.getId()); - // when & then mockMvc.perform(patch("/notifications/inbox/" + inbox.getId() + "/read") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); @@ -164,13 +181,14 @@ void markAsReadSuccess() throws Exception { @Test @DisplayName("다른 유저의 알림을 읽음 처리하면 404를 반환한다") void markAsReadOtherUserInboxFails() throws Exception { - // given NotificationInbox otherInbox = createInbox( - otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); + otherUser, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "다른 유저 알림" + ); clearPersistenceContext(); mockLoginUser(user.getId()); - // when & then mockMvc.perform(patch("/notifications/inbox/" + otherInbox.getId() + "/read") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()) @@ -183,15 +201,13 @@ void markAsReadOtherUserInboxFails() throws Exception { class MarkAllAsRead { @Test - @DisplayName("자신의 모든 알림을 읽음 처리한다") + @DisplayName("자신의 모든 일반 알림을 읽음 처리한다") void markAllAsReadSuccess() throws Exception { - // given createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); createInbox(user, NotificationInboxType.CLUB_APPLICATION_SUBMITTED, "알림2"); clearPersistenceContext(); mockLoginUser(user.getId()); - // when & then mockMvc.perform(patch("/notifications/inbox/read-all") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); @@ -202,21 +218,37 @@ void markAllAsReadSuccess() throws Exception { } @Test - @DisplayName("전체 읽음 처리 후 미읽음 개수가 0이 된다") - void markAllAsReadUpdatesUnreadCount() throws Exception { - // given + @DisplayName("채팅 관련 인앱 알림은 전체 읽음 처리에서 제외된다") + void markAllAsReadExcludesChatNotifications() throws Exception { + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "일반 알림"); + createInbox(user, NotificationInboxType.CHAT_MESSAGE, "채팅 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + mockMvc.perform(patch("/notifications/inbox/read-all") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + clearPersistenceContext(); + assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(user.getId())).isEqualTo(1L); + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(0)); + } + + @Test + @DisplayName("전체 읽음 처리는 다른 유저의 미읽음에 영향을 주지 않는다") + void markAllAsReadDoesNotAffectOtherUsersUnreadCount() throws Exception { createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); createInbox(user, NotificationInboxType.CLUB_APPLICATION_REJECTED, "알림2"); createInbox(otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); clearPersistenceContext(); mockLoginUser(user.getId()); - // when mockMvc.perform(patch("/notifications/inbox/read-all") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); - // then: 내 알림만 읽음 처리됨 clearPersistenceContext(); assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(user.getId())).isZero(); assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(otherUser.getId())).isEqualTo(1L); From f28a0c407a959d0f4e26edc2a0807e4f6ba336ad Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:24:34 +0900 Subject: [PATCH 48/55] =?UTF-8?q?fix:=20=EA=B5=AC=EA=B8=80=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20403=20=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 구글 시트 403 응답 처리 및 권한 부여 보강 * chore: 구글 시트 체크스타일 보정 * fix: 구글 시트 권한 예외 후속 처리 보완 * fix: 구글 시트 자격 검증 및 실패 감지 보완 * fix: 구글 시트 권한 부여 멱등성 및 Drive 인증 오류 처리 보완 * fix: 구글 시트 권한 정리 기준 및 예외 분류 보완 * fix: 구글 시트 reader 권한 중복 호출 제거 --- .../club/event/SheetSyncFailedEvent.java | 28 +++ .../service/ClubSheetIntegratedService.java | 4 + .../service/GoogleDrivePermissionHelper.java | 34 +++ .../GoogleSheetApiExceptionHelper.java | 95 ++++++++ .../service/GoogleSheetPermissionService.java | 222 ++++++++++++++++++ .../club/service/SheetHeaderMapper.java | 20 ++ .../club/service/SheetImportService.java | 8 + .../club/service/SheetMigrationService.java | 167 +++++++++++-- .../club/service/SheetSyncExecutor.java | 18 ++ .../konect/global/code/ApiResponseCode.java | 2 + .../global/exception/CustomException.java | 2 +- .../googlesheets/GoogleSheetsConfig.java | 28 ++- .../ClubSheetIntegratedServiceTest.java | 94 ++++++++ .../club/service/GoogleApiTestUtils.java | 38 +++ .../GoogleSheetApiExceptionHelperTest.java | 84 +++++++ .../GoogleSheetPermissionServiceTest.java | 219 +++++++++++++++++ .../club/service/SheetSyncExecutorTest.java | 108 +++++++++ .../club/ClubSheetMigrationApiTest.java | 23 ++ .../support/IntegrationTestSupport.java | 4 + 19 files changed, 1166 insertions(+), 32 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java new file mode 100644 index 00000000..76749c32 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java @@ -0,0 +1,28 @@ +package gg.agit.konect.domain.club.event; + +import java.time.LocalDateTime; + +public record SheetSyncFailedEvent( + Integer clubId, + String spreadsheetId, + boolean accessDenied, + String reason, + LocalDateTime occurredAt +) { + + public static SheetSyncFailedEvent accessDenied( + Integer clubId, + String spreadsheetId, + String reason + ) { + return new SheetSyncFailedEvent(clubId, spreadsheetId, true, reason, LocalDateTime.now()); + } + + public static SheetSyncFailedEvent unexpected( + Integer clubId, + String spreadsheetId, + String reason + ) { + return new SheetSyncFailedEvent(clubId, spreadsheetId, false, reason, LocalDateTime.now()); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java index d6b8ac7c..0f02bdb1 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -10,6 +10,7 @@ public class ClubSheetIntegratedService { private final ClubPermissionValidator clubPermissionValidator; + private final GoogleSheetPermissionService googleSheetPermissionService; private final SheetHeaderMapper sheetHeaderMapper; private final ClubMemberSheetService clubMemberSheetService; private final SheetImportService sheetImportService; @@ -22,6 +23,9 @@ public SheetImportResponse analyzeAndImportPreMembers( clubPermissionValidator.validateManagerAccess(clubId, requesterId); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + // Best-effort: OAuth 미연결/권한 부여 실패여도 이미 수동 공유된 시트는 그대로 읽을 수 있다. + googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java new file mode 100644 index 00000000..8f41fc16 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java @@ -0,0 +1,34 @@ +package gg.agit.konect.domain.club.service; + +final class GoogleDrivePermissionHelper { + + private static final int ROLE_RANK_NONE = 0; + private static final int ROLE_RANK_READER = 1; + private static final int ROLE_RANK_COMMENTER = 2; + private static final int ROLE_RANK_WRITER = 3; + + private GoogleDrivePermissionHelper() {} + + enum PermissionApplyStatus { + CREATED, + UPGRADED, + UNCHANGED + } + + static boolean hasRequiredRole(String currentRole, String targetRole) { + return roleRank(currentRole) >= roleRank(targetRole); + } + + private static int roleRank(String role) { + if (role == null) { + return ROLE_RANK_NONE; + } + + return switch (role) { + case "reader" -> ROLE_RANK_READER; + case "commenter" -> ROLE_RANK_COMMENTER; + case "writer", "fileOrganizer", "organizer", "owner" -> ROLE_RANK_WRITER; + default -> ROLE_RANK_NONE; + }; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java new file mode 100644 index 00000000..e7a650ea --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java @@ -0,0 +1,95 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpResponseException; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public final class GoogleSheetApiExceptionHelper { + + private static final Set ACCESS_DENIED_REASONS = Set.of( + "accessDenied", + "forbidden", + "insufficientFilePermissions", + "insufficientPermissions", + "notAuthorized", + "required" + ); + private static final Set AUTH_FAILURE_REASONS = Set.of( + "authError", + "invalidCredentials", + "unauthorized" + ); + private static final int HTTP_STATUS_FORBIDDEN = 403; + private static final int HTTP_STATUS_UNAUTHORIZED = 401; + private static final int HTTP_STATUS_NOT_FOUND = 404; + + private GoogleSheetApiExceptionHelper() {} + + public static boolean isAccessDenied(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + if (responseException.getStatusCode() != HTTP_STATUS_FORBIDDEN) { + return false; + } + return hasReason(responseException, ACCESS_DENIED_REASONS); + } + return getStatusCode(exception) == HTTP_STATUS_FORBIDDEN; + } + + public static boolean isAuthFailure(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + if (responseException.getStatusCode() != HTTP_STATUS_UNAUTHORIZED) { + return false; + } + return hasReason(responseException, AUTH_FAILURE_REASONS) + || !hasKnownReasons(responseException); + } + return getStatusCode(exception) == HTTP_STATUS_UNAUTHORIZED; + } + + public static boolean isNotFound(IOException exception) { + return getStatusCode(exception) == HTTP_STATUS_NOT_FOUND; + } + + public static CustomException accessDenied() { + return CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + } + + private static boolean hasReason( + GoogleJsonResponseException exception, + Set expectedReasons + ) { + return getReasons(exception).stream() + .anyMatch(expectedReasons::contains); + } + + private static boolean hasKnownReasons(GoogleJsonResponseException exception) { + return !getReasons(exception).isEmpty(); + } + + private static List getReasons(GoogleJsonResponseException exception) { + if (exception.getDetails() == null || exception.getDetails().getErrors() == null) { + return List.of(); + } + return exception.getDetails().getErrors().stream() + .map(GoogleJsonError.ErrorInfo::getReason) + .filter(reason -> reason != null && !reason.isBlank()) + .toList(); + } + + private static int getStatusCode(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + return responseException.getStatusCode(); + } + if (exception instanceof HttpResponseException responseException) { + return responseException.getStatusCode(); + } + return -1; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java new file mode 100644 index 00000000..33906432 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java @@ -0,0 +1,222 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleSheetPermissionService { + + private static final int PERMISSION_APPLY_MAX_ATTEMPTS = 2; + + private final ServiceAccountCredentials serviceAccountCredentials; + private final GoogleSheetsConfig googleSheetsConfig; + private final UserOAuthAccountRepository userOAuthAccountRepository; + + public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String spreadsheetId) { + String refreshToken = userOAuthAccountRepository + .findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .map(account -> account.getGoogleDriveRefreshToken()) + .filter(StringUtils::hasText) + .orElse(null); + + if (!StringUtils.hasText(refreshToken)) { + log.warn( + "Skipping service account auto-share because Google Drive OAuth is not connected. requesterId={}", + requesterId + ); + return false; + } + + Drive userDriveService; + try { + userDriveService = googleSheetsConfig.buildUserDriveService(refreshToken); + } catch (IOException | GeneralSecurityException e) { + log.error("Failed to build user Drive service. requesterId={}", requesterId, e); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + + try { + ensureServiceAccountPermission(userDriveService, spreadsheetId, "writer"); + return true; + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) + || GoogleSheetApiExceptionHelper.isAuthFailure(e) + || GoogleSheetApiExceptionHelper.isNotFound(e)) { + log.warn( + "Failed to auto-share spreadsheet with service account. requesterId={}, spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + e.getMessage() + ); + return false; + } + + log.error( + "Unexpected error while auto-sharing spreadsheet. requesterId={}, spreadsheetId={}", + requesterId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private void ensureServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole + ) throws IOException { + String serviceAccountEmail = getServiceAccountEmail(); + + for (int attempt = 1; attempt <= PERMISSION_APPLY_MAX_ATTEMPTS; attempt++) { + try { + applyServiceAccountPermission( + userDriveService, + fileId, + targetRole, + serviceAccountEmail + ); + return; + } catch (IOException e) { + if (hasRequiredPermission( + userDriveService, + fileId, + serviceAccountEmail, + targetRole + )) { + log.info( + "Service account permission reached target role after retry. fileId={}, role={}, email={}", + fileId, + targetRole, + serviceAccountEmail + ); + return; + } + + if (attempt == PERMISSION_APPLY_MAX_ATTEMPTS) { + throw e; + } + } + } + } + + private void applyServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole, + String serviceAccountEmail + ) throws IOException { + Permission existingPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + + if (existingPermission == null) { + Permission permission = new Permission() + .setType("user") + .setRole(targetRole) + .setEmailAddress(serviceAccountEmail); + + userDriveService.permissions().create(fileId, permission) + .setSendNotificationEmail(false) + .execute(); + log.info( + "Service account access granted. fileId={}, role={}, email={}", + fileId, + targetRole, + serviceAccountEmail + ); + return; + } + + String currentRole = existingPermission.getRole(); + if (GoogleDrivePermissionHelper.hasRequiredRole(currentRole, targetRole)) { + log.info( + "Service account permission already satisfies requested role. fileId={}, role={}, email={}", + fileId, + currentRole, + serviceAccountEmail + ); + return; + } + + Permission updatedPermission = new Permission().setRole(targetRole); + userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .execute(); + log.info( + "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", + fileId, + currentRole, + targetRole, + serviceAccountEmail + ); + } + + private boolean hasRequiredPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail, + String targetRole + ) { + try { + Permission currentPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + return currentPermission != null + && GoogleDrivePermissionHelper.hasRequiredRole(currentPermission.getRole(), targetRole); + } catch (IOException e) { + log.debug( + "Failed to re-check service account permission. fileId={}, email={}, cause={}", + fileId, + serviceAccountEmail, + e.getMessage() + ); + return false; + } + } + + private Permission findServiceAccountPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail + ) throws IOException { + List permissions = userDriveService.permissions().list(fileId) + .setFields("permissions(id,emailAddress,role)") + .execute() + .getPermissions(); + + if (permissions == null) { + return null; + } + + return permissions.stream() + .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) + .findFirst() + .orElse(null); + } + + private String getServiceAccountEmail() { + return serviceAccountCredentials.getClientEmail(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index 5b10c997..44c33c30 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -19,6 +19,7 @@ import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; import lombok.extern.slf4j.Slf4j; @@ -66,6 +67,8 @@ public SheetAnalysisResult analyzeAllSheets(String spreadsheetId) { try { return inferAllMappings(spreadsheetId, sheets); + } catch (CustomException e) { + throw e; } catch (Exception e) { log.warn("Sheet analysis failed, using default. cause={}", e.getMessage()); return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); @@ -90,6 +93,14 @@ private List readAllSheets(String spreadsheetId) { return result; } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading spreadsheet info. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to read spreadsheet info. spreadsheetId={}", spreadsheetId, e); return List.of(); } @@ -118,6 +129,15 @@ private List> readSheetRows(String spreadsheetId, String sheetTitle return rows; } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading sheet rows. spreadsheetId={}, sheetTitle={}, cause={}", + spreadsheetId, + sheetTitle, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.warn("Failed to read rows from sheet '{}'. cause={}", sheetTitle, e.getMessage()); return List.of(); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index d81b4390..45e10e63 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -243,6 +243,14 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping return values != null ? values : List.of(); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading sheet data. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to read sheet data. spreadsheetId={}", spreadsheetId, e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index c9439309..616d3873 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -22,7 +22,6 @@ import com.google.api.services.drive.model.Permission; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; -import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; import gg.agit.konect.domain.club.model.Club; @@ -53,7 +52,7 @@ public class SheetMigrationService { private String defaultTemplateSpreadsheetId; private final Sheets googleSheetsService; - private final GoogleCredentials googleCredentials; + private final ServiceAccountCredentials serviceAccountCredentials; private final SheetHeaderMapper sheetHeaderMapper; private final ClubRepository clubRepository; private final UserOAuthAccountRepository userOAuthAccountRepository; @@ -96,9 +95,12 @@ public String migrateToTemplate( String folderId = resolveFolderId(userDriveService, sourceSpreadsheetUrl, sourceSpreadsheetId); // 소스 파일에 서비스 계정 reader 권한을 먼저 부여해야 readAllData()가 성공함 - grantServiceAccountReadAccess(userDriveService, sourceSpreadsheetId); - // 트랜잭션 실패 / 완료 후 소스 파일 서비스 계정 권한 제거 (보상 처리) - registerSourceFilePermissionCleanup(userDriveService, sourceSpreadsheetId); + GoogleDrivePermissionHelper.PermissionApplyStatus sourcePermissionStatus = + grantServiceAccountReadAccess(userDriveService, sourceSpreadsheetId); + // 트랜잭션 실패 / 완료 후 이번 요청에서 추가한 소스 파일 권한만 정리한다. + if (sourcePermissionStatus == GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED) { + registerSourceFilePermissionCleanup(userDriveService, sourceSpreadsheetId); + } String newSpreadsheetId = copyTemplate(userDriveService, templateId, club.getName(), folderId); registerDriveRollback(userDriveService, newSpreadsheetId); @@ -141,8 +143,11 @@ public String migrateToTemplate( * 소스 파일에 서비스 계정 reader 권한을 부여합니다. * migrate 시 서비스 계정 Sheets API로 소스 데이터를 읽어야 하므로 필요합니다. */ - private void grantServiceAccountReadAccess(Drive userDriveService, String fileId) { - grantServiceAccountPermission(userDriveService, fileId, "reader"); + private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountReadAccess( + Drive userDriveService, + String fileId + ) { + return grantServiceAccountPermission(userDriveService, fileId, "reader"); } /** @@ -159,10 +164,7 @@ public void afterCompletion(int status) { } private void removeServiceAccountPermission(Drive driveService, String fileId) { - if (!(googleCredentials instanceof ServiceAccountCredentials sac)) { - return; - } - String serviceAccountEmail = sac.getClientEmail(); + String serviceAccountEmail = serviceAccountCredentials.getClientEmail(); try { // permissionId 조회 후 삭제 (getPermissions()는 빈 경우 null 반환 가능) List permissions = @@ -205,33 +207,114 @@ private void grantServiceAccountAccess(Drive userDriveService, String fileId) { /** * 서비스 계정에 지정된 role로 Drive 접근 권한을 부여하는 공통 메서드입니다. */ - private void grantServiceAccountPermission(Drive userDriveService, String fileId, String role) { - if (!(googleCredentials instanceof ServiceAccountCredentials sac)) { - throw new IllegalStateException( - "Google credentials is not a ServiceAccountCredentials. actual type=" - + googleCredentials.getClass().getName() + private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountPermission( + Drive userDriveService, + String fileId, + String role + ) { + try { + return ensureServiceAccountPermission(userDriveService, fileId, role); + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive auth failed while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + e.getMessage() + ); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error( + "Failed to grant service account {} access. fileId={}, cause={}", + role, fileId, e.getMessage(), e ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } - String serviceAccountEmail = sac.getClientEmail(); - try { + } + + private GoogleDrivePermissionHelper.PermissionApplyStatus ensureServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole + ) throws IOException { + String serviceAccountEmail = serviceAccountCredentials.getClientEmail(); + Permission existingPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + + if (existingPermission == null) { Permission permission = new Permission() .setType("user") - .setRole(role) + .setRole(targetRole) .setEmailAddress(serviceAccountEmail); userDriveService.permissions().create(fileId, permission) .setSendNotificationEmail(false) .execute(); log.info( "Service account {} access granted. fileId={}, email={}", - role, fileId, serviceAccountEmail + targetRole, + fileId, + serviceAccountEmail ); - } catch (IOException e) { - log.error( - "Failed to grant service account {} access. fileId={}, cause={}", - role, fileId, e.getMessage(), e + return GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED; + } + + String currentRole = existingPermission.getRole(); + if (GoogleDrivePermissionHelper.hasRequiredRole(currentRole, targetRole)) { + log.info( + "Service account permission already satisfies requested role. fileId={}, role={}, email={}", + fileId, + currentRole, + serviceAccountEmail ); - throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + return GoogleDrivePermissionHelper.PermissionApplyStatus.UNCHANGED; } + + Permission updatedPermission = new Permission().setRole(targetRole); + userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .execute(); + log.info( + "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", + fileId, + currentRole, + targetRole, + serviceAccountEmail + ); + return GoogleDrivePermissionHelper.PermissionApplyStatus.UPGRADED; + } + + private Permission findServiceAccountPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail + ) throws IOException { + List permissions = userDriveService.permissions().list(fileId) + .setFields("permissions(id,type,emailAddress,role)") + .execute() + .getPermissions(); + + if (permissions == null) { + return null; + } + + return permissions.stream() + .filter(permission -> "user".equals(permission.getType())) + .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) + .findFirst() + .orElse(null); } private void registerDriveRollback(Drive driveService, String fileId) { @@ -331,6 +414,24 @@ private String copyTemplate(Drive driveService, String templateId, String clubNa if (newFileId != null) { deleteFile(driveService, newFileId); } + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive auth failed while copying template. templateId={}, targetFolderId={}, cause={}", + templateId, + targetFolderId, + e.getMessage() + ); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while copying template. templateId={}, targetFolderId={}, cause={}", + templateId, + targetFolderId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to copy template. cause={}", e.getMessage(), e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } @@ -352,6 +453,14 @@ private List> readAllData( return values != null ? values : List.of(); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading source data. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to read source data. cause={}", e.getMessage(), e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } @@ -395,6 +504,14 @@ private void writeToTemplate( ); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while writing template data. spreadsheetId={}, cause={}", + newSpreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } log.error("Failed to write data to template. cause={}", e.getMessage(), e); throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 5b58d63d..e36759cd 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +29,7 @@ import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; import com.google.api.services.sheets.v4.model.ValueRange; +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -56,6 +58,7 @@ public class SheetSyncExecutor { private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; private final ObjectMapper objectMapper; + private final ApplicationEventPublisher applicationEventPublisher; @Async("sheetSyncTaskExecutor") @Transactional(readOnly = true) @@ -79,10 +82,25 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as } log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied during sheet sync. clubId={}, spreadsheetId={}, cause={}", + clubId, + spreadsheetId, + e.getMessage() + ); + applicationEventPublisher.publishEvent( + SheetSyncFailedEvent.accessDenied(clubId, spreadsheetId, e.getMessage()) + ); + return; + } log.error( "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", clubId, spreadsheetId, e.getMessage(), e ); + applicationEventPublisher.publishEvent( + SheetSyncFailedEvent.unexpected(clubId, spreadsheetId, e.getMessage()) + ); } } diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index c28c6164..67e6dea5 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -71,6 +71,8 @@ public enum ApiResponseCode { FORBIDDEN_MEMBER_POSITION_CHANGE(HttpStatus.FORBIDDEN, "회원 직책 변경 권한이 없습니다."), FORBIDDEN_POSITION_NAME_CHANGE(HttpStatus.FORBIDDEN, "해당 직책의 이름은 변경할 수 없습니다."), FORBIDDEN_ROLE_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + FORBIDDEN_GOOGLE_SHEET_ACCESS(HttpStatus.FORBIDDEN, + "구글 스프레드시트 접근 권한이 없습니다. 서비스 계정을 공유하거나 Google Drive 권한을 연결한 뒤 다시 시도해 주세요."), FORBIDDEN_ORIGIN_ACCESS(HttpStatus.FORBIDDEN, "허용되지 않은 Origin 입니다."), FORBIDDEN_CHAT_ROOM_KICK(HttpStatus.FORBIDDEN, "채팅방 방장만 멤버를 강퇴할 수 있습니다."), diff --git a/src/main/java/gg/agit/konect/global/exception/CustomException.java b/src/main/java/gg/agit/konect/global/exception/CustomException.java index 777c1721..ee19d0a1 100644 --- a/src/main/java/gg/agit/konect/global/exception/CustomException.java +++ b/src/main/java/gg/agit/konect/global/exception/CustomException.java @@ -27,7 +27,7 @@ public static CustomException of(ApiResponseCode errorCode, String detail) { } public String getFullMessage() { - if (StringUtils.hasText(detail)) { + if (!StringUtils.hasText(detail)) { return super.getMessage(); } return String.format("%s: %s", getMessage(), detail); diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index 1e4ed426..52cee7d5 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; @@ -21,6 +22,7 @@ import com.google.api.services.sheets.v4.SheetsScopes; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import com.google.auth.oauth2.UserCredentials; import lombok.RequiredArgsConstructor; @@ -33,16 +35,30 @@ public class GoogleSheetsConfig { private final ResourceLoader resourceLoader; @Bean - public GoogleCredentials googleCredentials() throws IOException { + public ServiceAccountCredentials serviceAccountCredentials() throws IOException { try (InputStream in = openCredentialsStream()) { - return GoogleCredentials.fromStream(in) - .createScoped(Arrays.asList( - SheetsScopes.SPREADSHEETS, - DriveScopes.DRIVE - )); + GoogleCredentials credentials = GoogleCredentials.fromStream(in); + if (!(credentials instanceof ServiceAccountCredentials serviceAccountCredentials)) { + throw new IllegalStateException( + "Google credentials must be ServiceAccountCredentials. actual type=" + + credentials.getClass().getName() + ); + } + return serviceAccountCredentials; } } + @Bean + @Primary + public GoogleCredentials googleCredentials( + ServiceAccountCredentials serviceAccountCredentials + ) { + return serviceAccountCredentials.createScoped(Arrays.asList( + SheetsScopes.SPREADSHEETS, + DriveScopes.DRIVE + )); + } + @Bean public Sheets googleSheetsService( GoogleCredentials googleCredentials diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java index 873403e9..700737c6 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java @@ -1,8 +1,10 @@ package gg.agit.konect.domain.club.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verifyNoInteractions; import java.util.List; @@ -14,6 +16,8 @@ import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.ServiceTestSupport; class ClubSheetIntegratedServiceTest extends ServiceTestSupport { @@ -24,6 +28,9 @@ class ClubSheetIntegratedServiceTest extends ServiceTestSupport { @Mock private SheetHeaderMapper sheetHeaderMapper; + @Mock + private GoogleSheetPermissionService googleSheetPermissionService; + @Mock private ClubMemberSheetService clubMemberSheetService; @@ -46,6 +53,8 @@ void analyzeAndImportPreMembersSuccess() { new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); SheetImportResponse expected = SheetImportResponse.of(3, 1, List.of("warn")); + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willReturn(true); given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); given(sheetImportService.importPreMembersFromSheet( clubId, @@ -65,11 +74,14 @@ void analyzeAndImportPreMembersSuccess() { // then InOrder inOrder = inOrder( clubPermissionValidator, + googleSheetPermissionService, sheetHeaderMapper, clubMemberSheetService, sheetImportService ); inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); inOrder.verify(clubMemberSheetService).updateSheetId( clubId, @@ -85,4 +97,86 @@ void analyzeAndImportPreMembersSuccess() { ); assertThat(actual).isEqualTo(expected); } + + @Test + @DisplayName("자동 권한 부여가 실패해도 기존 공유 권한으로 가져오기를 계속 시도한다") + void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + SheetHeaderMapper.SheetAnalysisResult analysis = + new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + SheetImportResponse expected = SheetImportResponse.of(1, 0, List.of()); + + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willReturn(false); + given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); + given(sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + )) + .willReturn(expected); + + // when + SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + ); + + // then + InOrder inOrder = inOrder( + clubPermissionValidator, + googleSheetPermissionService, + sheetHeaderMapper, + clubMemberSheetService, + sheetImportService + ); + inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); + inOrder.verify(clubMemberSheetService).updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysis + ); + inOrder.verify(sheetImportService).importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") + void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willThrow(expected); + + // when & then + assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + )) + .isSameAs(expected); + verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java new file mode 100644 index 00000000..8cb42187 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java @@ -0,0 +1,38 @@ +package gg.agit.konect.domain.club.service; + +import java.util.List; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; + +final class GoogleApiTestUtils { + + private GoogleApiTestUtils() {} + + static GoogleJsonResponseException googleException(int statusCode, String reason) { + GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); + errorInfo.setReason(reason); + + GoogleJsonError error = new GoogleJsonError(); + error.setCode(statusCode); + error.setErrors(List.of(errorInfo)); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ); + + return new GoogleJsonResponseException(builder, error); + } + + static HttpResponseException httpResponseException(int statusCode) { + return new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ).build(); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java new file mode 100644 index 00000000..644b6da7 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java @@ -0,0 +1,84 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; + +class GoogleSheetApiExceptionHelperTest { + + @Test + @DisplayName("classifies only permission-related 403 reasons as access denied") + void isAccessDeniedReturnsTrueOnlyForPermissionReasons() { + GoogleJsonResponseException permissionDenied = + googleException(403, "insufficientPermissions"); + GoogleJsonResponseException accessDenied = + googleException(403, "accessDenied"); + GoogleJsonResponseException forbidden = + googleException(403, "forbidden"); + GoogleJsonResponseException quotaExceeded = + googleException(403, "quotaExceeded"); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(permissionDenied)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(accessDenied)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(forbidden)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(quotaExceeded)).isFalse(); + } + + @Test + @DisplayName("returns false for 403 responses without a reason") + void isAccessDeniedReturnsFalseWhenReasonIsMissing() { + GoogleJsonError error = new GoogleJsonError(); + error.setCode(403); + error.setErrors(List.of()); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + 403, + null, + new HttpHeaders() + ); + + GoogleJsonResponseException exception = new GoogleJsonResponseException(builder, error); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(exception)).isFalse(); + } + + @Test + @DisplayName("classifies auth-related 401 as auth failure") + void isAuthFailureReturnsTrueForAuthReasons() { + GoogleJsonResponseException authFailure = + googleException(401, "authError"); + + assertThat(GoogleSheetApiExceptionHelper.isAuthFailure(authFailure)).isTrue(); + } + + @Test + @DisplayName("classifies 404 as not found") + void isNotFoundReturnsTrueFor404() { + GoogleJsonResponseException notFound = + googleException(404, "notFound"); + + assertThat(GoogleSheetApiExceptionHelper.isNotFound(notFound)).isTrue(); + } + + @Test + @DisplayName("classifies non-json http response exceptions with their status code") + void classifiesNonJsonHttpResponseExceptionByStatusCode() { + HttpResponseException forbidden = httpResponseException(403); + HttpResponseException unauthorized = httpResponseException(401); + HttpResponseException notFound = httpResponseException(404); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(forbidden)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAuthFailure(unauthorized)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isNotFound(notFound)).isTrue(); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java new file mode 100644 index 00000000..552aa895 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -0,0 +1,219 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import gg.agit.konect.support.ServiceTestSupport; + +class GoogleSheetPermissionServiceTest extends ServiceTestSupport { + + private static final Integer REQUESTER_ID = 1; + private static final String FILE_ID = "spreadsheet-id"; + private static final String REFRESH_TOKEN = "refresh-token"; + private static final String SERVICE_ACCOUNT_EMAIL = "service-account@konect.iam.gserviceaccount.com"; + + @Mock + private ServiceAccountCredentials serviceAccountCredentials; + + @Mock + private GoogleSheetsConfig googleSheetsConfig; + + @Mock + private UserOAuthAccountRepository userOAuthAccountRepository; + + @Mock + private UserOAuthAccount userOAuthAccount; + + @Mock + private Drive userDriveService; + + @Mock + private Drive.Permissions permissions; + + @Mock + private Drive.Permissions.List listRequest; + + @Mock + private Drive.Permissions.Create createRequest; + + @Mock + private Drive.Permissions.Update updateRequest; + + @InjectMocks + private GoogleSheetPermissionService googleSheetPermissionService; + + @Test + @DisplayName("returns false when the requester has no Google Drive OAuth account") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { + // given + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns true without creating when the service account already has writer access") + void tryGrantServiceAccountWriterAccessReturnsTrueWhenPermissionAlreadyExists() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isTrue(); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + verify(permissions, never()).update(eq(FILE_ID), eq("perm-1"), any(Permission.class)); + } + + @Test + @DisplayName("returns true when create fails but the permission is visible on re-check") + void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willReturn( + permissionList(), + permissionList(permission("perm-1", "writer")) + ); + given(permissions.create(eq(FILE_ID), any(Permission.class))).willReturn(createRequest); + given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.execute()).willThrow(new IOException("already granted")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isTrue(); + verify(permissions).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("returns true when an existing permission needs to be upgraded to writer") + void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-x", "reader"))); + given(permissions.update(eq(FILE_ID), eq("perm-x"), any(Permission.class))).willReturn(updateRequest); + given(updateRequest.execute()).willReturn(permission("perm-x", "writer")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isTrue(); + verify(permissions).update(eq(FILE_ID), eq("perm-x"), any(Permission.class)); + } + + @Test + @DisplayName("returns false when Google Drive auth fails during permission lookup") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(401, "authError")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns false when Google Drive reports access denied while listing permissions") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() + throws IOException, GeneralSecurityException { + // given + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + + // when + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + // then + assertThat(granted).isFalse(); + } + + private void mockConnectedDriveAccount() throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(serviceAccountCredentials.getClientEmail()).willReturn(SERVICE_ACCOUNT_EMAIL); + given(userDriveService.permissions()).willReturn(permissions); + } + + private Permission permission(String id, String role) { + return new Permission() + .setId(id) + .setType("user") + .setEmailAddress(SERVICE_ACCOUNT_EMAIL) + .setRole(role); + } + + private PermissionList permissionList(Permission... permissions) { + return new PermissionList().setPermissions(List.of(permissions)); + } + +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java new file mode 100644 index 00000000..1ac3d45f --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java @@ -0,0 +1,108 @@ +package gg.agit.konect.domain.club.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class SheetSyncExecutorTest extends ServiceTestSupport { + + @Mock + private Sheets googleSheetsService; + + @Mock + private Sheets.Spreadsheets spreadsheets; + + @Mock + private Sheets.Spreadsheets.Values values; + + @Mock + private Sheets.Spreadsheets.Values.Clear clearRequest; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private SheetSyncExecutor sheetSyncExecutor; + + @Test + @DisplayName("시트 동기화 권한 오류는 실패 이벤트로 발행한다") + void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) + .willReturn(clearRequest); + given(clearRequest.execute()).willThrow(googleException(403, "accessDenied")); + + // when + sheetSyncExecutor.executeWithSort(clubId, ClubSheetSortKey.NAME, true); + + // then + verify(applicationEventPublisher).publishEvent(argThat((Object event) -> + event instanceof SheetSyncFailedEvent sheetSyncFailedEvent + && sheetSyncFailedEvent.clubId().equals(clubId) + && sheetSyncFailedEvent.spreadsheetId().equals(spreadsheetId) + && sheetSyncFailedEvent.accessDenied() + )); + } + + private GoogleJsonResponseException googleException(int statusCode, String reason) { + GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); + errorInfo.setReason(reason); + + GoogleJsonError error = new GoogleJsonError(); + error.setCode(statusCode); + error.setErrors(List.of(errorInfo)); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ); + + return new GoogleJsonResponseException(builder, error); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java index f906a6e3..6adff35e 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -16,6 +16,8 @@ import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.IntegrationTestSupport; class ClubSheetMigrationApiTest extends IntegrationTestSupport { @@ -56,5 +58,26 @@ void analyzeAndImportPreMembersSuccess() throws Exception { .andExpect(jsonPath("$.autoRegisteredCount").value(1)) .andExpect(jsonPath("$.warnings[0]").value("전화번호 형식 경고")); } + + @Test + @DisplayName("구글 스프레드시트 403 오류를 response body로 반환한다") + void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { + // given + given(clubSheetIntegratedService.analyzeAndImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS)); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + // when & then + performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); + } } } diff --git a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java index 157a152d..84b44df8 100644 --- a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java +++ b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java @@ -27,6 +27,7 @@ import com.google.api.services.drive.Drive; import com.google.api.services.sheets.v4.Sheets; import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import jakarta.persistence.EntityManager; @@ -77,6 +78,9 @@ public abstract class IntegrationTestSupport { @MockitoBean protected GoogleCredentials googleCredentials; + @MockitoBean + protected ServiceAccountCredentials serviceAccountCredentials; + @MockitoBean protected Sheets googleSheetsService; From 0756c6ac4ac41364807c5cef4bfdc29894189b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:53:10 +0900 Subject: [PATCH 49/55] =?UTF-8?q?test:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EC=A4=91=EC=A7=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 타이머 동기화 및 중지 테스트 케이스 추가 * refactor: remove duplicate test stopWithoutTimerFails --- .../domain/studytime/StudyTimeApiTest.java | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java index eaf6d07e..9f5a8402 100644 --- a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java @@ -4,6 +4,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.LocalDate; +import java.time.LocalDateTime; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -11,6 +14,14 @@ import org.springframework.beans.factory.annotation.Autowired; import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; +import gg.agit.konect.domain.studytime.model.StudyTimeDaily; +import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; +import gg.agit.konect.domain.studytime.model.StudyTimeTotal; +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.model.User; @@ -25,6 +36,15 @@ class StudyTimeApiTest extends IntegrationTestSupport { @Autowired private StudyTimerRepository studyTimerRepository; + @Autowired + private StudyTimeDailyRepository studyTimeDailyRepository; + + @Autowired + private StudyTimeMonthlyRepository studyTimeMonthlyRepository; + + @Autowired + private StudyTimeTotalRepository studyTimeTotalRepository; + private University university; private User user; @@ -156,5 +176,202 @@ void stopTimerWithTimeMismatchFails() throws Exception { performDelete("/studytimes/timers", request) .andExpect(status().isBadRequest()); } + + @Test + @DisplayName("타이머 중지 후 시간이 누적된다") + void stopTimerAccumulatesTime() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); + persist(timer); + clearPersistenceContext(); + + StudyTimerStopRequest request = new StudyTimerStopRequest(5L); + + // when + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + StudyTimeDaily daily = studyTimeDailyRepository + .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) + .orElse(null); + StudyTimeMonthly monthly = studyTimeMonthlyRepository + .findByUserIdAndStudyMonth(user.getId(), LocalDate.now().withDayOfMonth(1)) + .orElse(null); + StudyTimeTotal total = studyTimeTotalRepository.findByUserId(user.getId()).orElse(null); + + assertThat(daily).isNotNull(); + assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + assertThat(monthly).isNotNull(); + assertThat(monthly.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + assertThat(total).isNotNull(); + assertThat(total.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + } + } + + @Nested + @DisplayName("POST /studytimes/timers/sync - 타이머 동기화") + class SyncTimer { + + @Test + @DisplayName("타이머를 동기화하면 시간이 누적되고 시작 시간이 갱신된다") + void syncTimerAccumulatesTime() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + LocalDateTime originalStartedAt = timer.getStartedAt(); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); + persist(timer); + clearPersistenceContext(); + + StudyTimerSyncRequest request = new StudyTimerSyncRequest(5L); + + // when + performPost("/studytimes/timers/sync", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + StudyTimer updatedTimer = studyTimerRepository.getByUserId(user.getId()); + assertThat(updatedTimer.getStartedAt()).isAfter(originalStartedAt); + + StudyTimeDaily daily = studyTimeDailyRepository + .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) + .orElse(null); + assertThat(daily).isNotNull(); + assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + } + + @Test + @DisplayName("실행 중인 타이머가 없으면 동기화에 실패한다") + void syncTimerWithoutRunningFails() throws Exception { + // given + mockLoginUser(user.getId()); + StudyTimerSyncRequest request = new StudyTimerSyncRequest(0L); + + // when & then + performPost("/studytimes/timers/sync", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("클라이언트 시간과 서버 시간이 크게 차이나면 타이머가 삭제된다") + void syncTimerWithTimeMismatchDeletesTimer() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimerSyncRequest request = new StudyTimerSyncRequest(MISMATCHED_CLIENT_SECONDS); + + // when & then + performPost("/studytimes/timers/sync", request) + .andExpect(status().isBadRequest()); + + assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); + } + + @Test + @DisplayName("여러 번 동기화해도 시간이 정확히 누적된다") + void multipleSyncAccumulatesCorrectly() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + // 첫 번째 동기화 + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(3)); + persist(timer); + clearPersistenceContext(); + + performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(3L)) + .andExpect(status().isOk()); + + // 두 번째 동기화 + timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); + persist(timer); + clearPersistenceContext(); + + performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(5L)) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + StudyTimeDaily daily = studyTimeDailyRepository + .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) + .orElse(null); + assertThat(daily).isNotNull(); + assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(8L); + } + } + + @Nested + @DisplayName("타이머 엣지 케이스") + class TimerEdgeCases { + + @Test + @DisplayName("타이머 시작 후 즉시 중지해도 정상 동작한다") + void stopImmediatelyAfterStart() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimerStopRequest request = new StudyTimerStopRequest(0L); + + // when & then + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()); + + assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); + } + + @Test + @DisplayName("타이머 시작 후 3초 이내의 시간 차이는 허용된다") + void timerAllowsSmallTimeDifference() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(1)); + persist(timer); + clearPersistenceContext(); + + // 1초 차이는 3초 임계값 이내 + StudyTimerStopRequest request = new StudyTimerStopRequest(1L); + + // when & then + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("0초 동안 타이머를 실행해도 정상 동작한다") + void timerWithZeroSeconds() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimerStopRequest request = new StudyTimerStopRequest(0L); + + // when & then + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sessionSeconds").value(0)); + } } } From a4fbc3f6fec24f14b95656d346039c3e6ae87c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:57:27 +0900 Subject: [PATCH 50/55] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 회원가입 API 통합 테스트 추가 * chore: 코드 포맷팅 --- .../domain/user/UserSignupApiTest.java | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java new file mode 100644 index 00000000..15dd2282 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java @@ -0,0 +1,368 @@ +package gg.agit.konect.integration.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.dto.SignupRequest; +import gg.agit.konect.domain.user.model.UnRegisteredUser; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UnRegisteredUserFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import jakarta.servlet.http.Cookie; + +@DisplayName("회원가입 API 테스트") +class UserSignupApiTest extends IntegrationTestSupport { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UnRegisteredUserRepository unRegisteredUserRepository; + + @Autowired + private ClubPreMemberRepository clubPreMemberRepository; + + @Autowired + private ClubMemberRepository clubMemberRepository; + + private static final String SIGNUP_TOKEN_COOKIE_NAME = "signup_token"; + private static final String VALID_SIGNUP_TOKEN = "valid-test-signup-token"; + + private University university; + private Club club; + private User existingPresident; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + club = persist(ClubFixture.create(university, "BCSD Lab")); + existingPresident = persist(UserFixture.createUser(university, "기존회장", "2020000001")); + persist(gg.agit.konect.support.fixture.ClubMemberFixture.createPresident(club, existingPresident)); + clearPersistenceContext(); + + // signup_token 쿠키 설정 + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/test-setup") + .cookie(new Cookie(SIGNUP_TOKEN_COOKIE_NAME, VALID_SIGNUP_TOKEN))); + } + + @Nested + @DisplayName("POST /users/signup - 회원가입") + class Signup { + + @Test + @DisplayName("정상 회원가입을 성공한다") + void signupSuccess() throws Exception { + // given + String email = "newuser@koreatech.ac.kr"; + String studentNumber = "2021136001"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest( + "홍길동", + university.getId(), + studentNumber, + true + ); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // 회원이 생성되었는지 확인 + clearPersistenceContext(); + User savedUser = userRepository.findAll().stream() + .filter(u -> u.getStudentNumber().equals(studentNumber)) + .findFirst() + .orElse(null); + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getName()).isEqualTo("홍길동"); + assertThat(savedUser.getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("회원가입 시 PreMember가 있으면 자동으로 동아리에 가입된다") + void signupWithPreMemberAutoJoinsClub() throws Exception { + // given + String email = "premember@koreatech.ac.kr"; + String studentNumber = "2021136002"; + String name = "김프리"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // PreMember로 등록 (MEMBER 직급) + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.MEMBER) + .build(); + persist(preMember); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = userRepository.findAll().stream() + .filter(u -> u.getStudentNumber().equals(studentNumber)) + .findFirst() + .orElse(null); + assertThat(savedUser).isNotNull(); + + // 동아리 멤버로 등록되었는지 확인 + boolean isMember = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(isMember).isTrue(); + + ClubMember clubMember = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(clubMember.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + + // PreMember는 삭제되었는지 확인 + List remainingPreMembers = clubPreMemberRepository.findAllByClubId(club.getId()); + assertThat(remainingPreMembers).isEmpty(); + } + + @Test + @DisplayName("회원가입 시 PreMember가 회장이면 기존 회장을 교체한다") + void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { + // given + String email = "newpresident@koreatech.ac.kr"; + String studentNumber = "2021136003"; + String name = "신임회장"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // PreMember로 회장 등록 + ClubPreMember preMemberPresident = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.PRESIDENT) + .build(); + persist(preMemberPresident); + clearPersistenceContext(); + + // 기존 회장이 존재하는지 확인 + assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = userRepository.findAll().stream() + .filter(u -> u.getStudentNumber().equals(studentNumber)) + .findFirst() + .orElse(null); + assertThat(savedUser).isNotNull(); + + // 새로운 사용자가 회장으로 등록되었는지 확인 + ClubMember newPresident = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(newPresident.getClubPosition()).isEqualTo(ClubPosition.PRESIDENT); + + // 기존 회장은 삭제되었는지 확인 + assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); + assertThat(clubMemberRepository.findPresidentByClubId(club.getId()).get().getUser().getId()) + .isEqualTo(savedUser.getId()); + } + + @Test + @DisplayName("회원가입 시 복수 동아리의 PreMember가 있으면 모두 가입된다") + void signupWithMultiplePreMembersJoinsAllClubs() throws Exception { + // given + String email = "multi@koreatech.ac.kr"; + String studentNumber = "2021136004"; + String name = "멀티동아리"; + + Club club2 = persist(ClubFixture.create(university, "Another Club")); + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // 두 동아리에 PreMember 등록 + ClubPreMember preMember1 = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.MEMBER) + .build(); + ClubPreMember preMember2 = ClubPreMember.builder() + .club(club2) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.MANAGER) + .build(); + persist(preMember1); + persist(preMember2); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = userRepository.findAll().stream() + .filter(u -> u.getStudentNumber().equals(studentNumber)) + .findFirst() + .orElse(null); + assertThat(savedUser).isNotNull(); + + // 두 동아리 모두 가입되었는지 확인 + boolean isMemberOfClub1 = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); + boolean isMemberOfClub2 = clubMemberRepository.existsByClubIdAndUserId(club2.getId(), savedUser.getId()); + assertThat(isMemberOfClub1).isTrue(); + assertThat(isMemberOfClub2).isTrue(); + + // 각각의 직급 확인 + ClubMember memberInClub1 = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); + ClubMember memberInClub2 = clubMemberRepository.getByClubIdAndUserId(club2.getId(), savedUser.getId()); + assertThat(memberInClub1.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(memberInClub2.getClubPosition()).isEqualTo(ClubPosition.MANAGER); + } + + @Test + @DisplayName("회원가입 시 이름이 유효하지 않으면 400을 반환한다") + void signupWithInvalidNameReturns400() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + // 이름 1글자 (유효하지 않음) + SignupRequest request = new SignupRequest("홍", university.getId(), "2021136005", true); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 시 학번이 숫자가 아니면 400을 반환한다") + void signupWithNonNumericStudentNumberReturns400() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest("홍길동", university.getId(), "ABC123", true); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 시 존재하지 않는 대학 ID면 404를 반환한다") + void signupWithNonExistentUniversityReturns404() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest("홍길동", 99999, "2021136006", true); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("회원가입 시 마케팅 동의 여부가 null이면 400을 반환한다") + void signupWithNullMarketingAgreementReturns400() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + // 마케팅 동의 null인 DTO 생성 + String jsonRequest = String.format( + "{\"name\": \"홍길동\", \"universityId\": %d, \"studentNumber\": \"2021136007\"}", + university.getId() + ); + + // when & then + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/users/signup") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PreMember가 없는 이름/학번 조합이면 자동 가입되지 않는다") + void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { + // given + String email = "nomatch@koreatech.ac.kr"; + String studentNumber = "2021136008"; + String name = "노매치"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // 다른 학번으로 PreMember 등록 + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber("99999999") // 다른 학번 + .name(name) + .clubPosition(ClubPosition.MEMBER) + .build(); + persist(preMember); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = userRepository.findAll().stream() + .filter(u -> u.getStudentNumber().equals(studentNumber)) + .findFirst() + .orElse(null); + assertThat(savedUser).isNotNull(); + + // 동아리에 가입되지 않았는지 확인 + boolean isMember = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(isMember).isFalse(); + } + } +} From 13b7f105bcf376ef4ae2662c562d1487210ddca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:57:41 +0900 Subject: [PATCH 51/55] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#488)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 회원 탈퇴 API 통합 테스트 추가 * chore: 코드 포맷팅 --- .../domain/user/UserWithdrawApiTest.java | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java new file mode 100644 index 00000000..d2e87ea1 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java @@ -0,0 +1,227 @@ +package gg.agit.konect.integration.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +@DisplayName("회원 탈퇴 API 테스트") +class UserWithdrawApiTest extends IntegrationTestSupport { + + @Autowired + private UserRepository userRepository; + + @Autowired + private ClubMemberRepository clubMemberRepository; + + private University university; + private Club club; + + @BeforeEach + void setUp() { + university = persist(UniversityFixture.create()); + club = persist(ClubFixture.create(university, "BCSD Lab")); + } + + @Nested + @DisplayName("DELETE /users/withdraw - 회원 탈퇴") + class Withdraw { + + @Test + @DisplayName("일반 멤버는 탈퇴할 수 있다") + void withdrawAsRegularMemberSuccess() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "일반회원", "2021136001")); + persist(ClubMemberFixture.createMember(club, user)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + // 탈퇴 처리되었는지 확인 + clearPersistenceContext(); + User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + assertThat(withdrawnUser).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("동아리에 가입하지 않은 사용자도 탈퇴할 수 있다") + void withdrawWithoutClubMembershipSuccess() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "미가입자", "2021136002")); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + assertThat(withdrawnUser).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("회장은 탈퇴할 수 없다") + void withdrawAsPresidentFails() throws Exception { + // given + User president = persist(UserFixture.createUser(university, "회장", "2021136003")); + persist(ClubMemberFixture.createPresident(club, president)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isBadRequest()); + + // 탈퇴 처리되지 않았는지 확인 + clearPersistenceContext(); + User notWithdrawnUser = userRepository.findById(president.getId()).orElse(null); + assertThat(notWithdrawnUser).isNotNull(); + assertThat(notWithdrawnUser.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("복수 동아리의 회장이면 탈퇴할 수 없다") + void withdrawAsPresidentOfMultipleClubsFails() throws Exception { + // given + Club club2 = persist(ClubFixture.create(university, "Another Club")); + User president = persist(UserFixture.createUser(university, "다중회장", "2021136004")); + + persist(ClubMemberFixture.createPresident(club, president)); + persist(ClubMemberFixture.createPresident(club2, president)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isBadRequest()); + + clearPersistenceContext(); + User notWithdrawnUser = userRepository.findById(president.getId()).orElse(null); + assertThat(notWithdrawnUser).isNotNull(); + assertThat(notWithdrawnUser.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("한 동아리의 회장이고 다른 동아리의 일반 멤버면 탈퇴할 수 없다") + void withdrawAsPresidentInOneClubAndMemberInAnotherFails() throws Exception { + // given + Club club2 = persist(ClubFixture.create(university, "Another Club")); + User user = persist(UserFixture.createUser(university, "회장이자일반", "2021136005")); + + persist(ClubMemberFixture.createPresident(club, user)); + persist(ClubMemberFixture.createMember(club2, user)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("부회장은 탈퇴할 수 있다") + void withdrawAsVicePresidentSuccess() throws Exception { + // given + User vicePresident = persist(UserFixture.createUser(university, "부회장", "2021136006")); + persist(ClubMemberFixture.createVicePresident(club, vicePresident)); + clearPersistenceContext(); + + mockLoginUser(vicePresident.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("운영진은 탈퇴할 수 있다") + void withdrawAsManagerSuccess() throws Exception { + // given + User manager = persist(UserFixture.createUser(university, "운영진", "2021136007")); + persist(ClubMemberFixture.createManager(club, manager)); + clearPersistenceContext(); + + mockLoginUser(manager.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("탈퇴 후 재가입을 위해 7일 유예기간이 설정된다") + void withdrawSetsDeletedAt() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "탈퇴자", "2021136008")); + persist(ClubMemberFixture.createMember(club, user)); + clearPersistenceContext(); + + LocalDateTime beforeWithdraw = LocalDateTime.now(); + mockLoginUser(user.getId()); + + // when + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + // then + clearPersistenceContext(); + User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + assertThat(withdrawnUser).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isAfterOrEqualTo(beforeWithdraw); + } + + @Test + @DisplayName("이미 탈퇴한 사용자가 다시 탈퇴하면 정상 처리된다") + void doubleWithdrawSucceeds() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "이중탈퇴", "2021136009")); + persist(ClubMemberFixture.createMember(club, user)); + user.withdraw(LocalDateTime.now().minusDays(1)); + persist(user); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("미인증 사용자는 탈퇴할 수 없다") + void withdrawWithoutAuthFails() throws Exception { + // when & then + performDelete("/users/withdraw") + .andExpect(status().isUnauthorized()); + } + } +} From e605057e9ea25541789d7e2e4f119d76b4e4710c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:04:33 +0900 Subject: [PATCH 52/55] =?UTF-8?q?test:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EB=A9=A4=EB=B2=84=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=84=9C=20API=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#486)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 동아리 승인 멤버 지원서 API 통합 테스트 추가 * chore: 코드 포맷팅 * fix: 미사용 변수 제거 Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: 미사용 변수 제거 Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../club/ClubMemberApplicationsApiTest.java | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java new file mode 100644 index 00000000..42bca00c --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java @@ -0,0 +1,325 @@ +package gg.agit.konect.integration.domain.club; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubApply; +import gg.agit.konect.domain.club.model.ClubApplyAnswer; +import gg.agit.konect.domain.club.model.ClubApplyQuestion; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.club.repository.ClubApplyAnswerRepository; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.ClubRecruitmentFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +@DisplayName("동아리 승인 멤버 지원서 API 테스트") +class ClubMemberApplicationsApiTest extends IntegrationTestSupport { + + @Autowired + private ClubApplyRepository clubApplyRepository; + + @Autowired + private ClubApplyAnswerRepository clubApplyAnswerRepository; + + @Autowired + private ClubMemberRepository clubMemberRepository; + + private University university; + private Club club; + private User president; + private ClubRecruitment recruitment; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + president = persist(UserFixture.createUser(university, "회장", "2020000001")); + club = persist(ClubFixture.createWithRecruitment(university, "BCSD Lab")); + persist(ClubMemberFixture.createPresident(club, president)); + recruitment = persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); + } + + @Nested + @DisplayName("GET /clubs/{clubId}/member-applications - 승인된 멤버 지원서 목록") + class GetApprovedMemberApplications { + + @Test + @DisplayName("승인된 멤버들의 지원서 목록을 조회한다") + void getApprovedMemberApplicationsSuccess() throws Exception { + // given + User approvedUser1 = persist(UserFixture.createUser(university, "승인자1", "2021000001")); + User approvedUser2 = persist(UserFixture.createUser(university, "승인자2", "2021000002")); + User pendingUser = persist(UserFixture.createUser(university, "대기자", "2021000003")); + + // 승인된 지원서 + ClubApply approvedApply1 = ClubApply.of(club, approvedUser1, null); + ClubApply approvedApply2 = ClubApply.of(club, approvedUser2, null); + approvedApply1.approve(); + approvedApply2.approve(); + persist(approvedApply1); + persist(approvedApply2); + + // 멤버로 등록 + persist(ClubMemberFixture.createMember(club, approvedUser1)); + persist(ClubMemberFixture.createMember(club, approvedUser2)); + + // 대기중인 지원서 + persist(ClubApply.of(club, pendingUser, null)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(2))) + .andExpect(jsonPath("$.applications[0].name").exists()) + .andExpect(jsonPath("$.applications[1].name").exists()); + } + + @Test + @DisplayName("승인된 멤버가 없으면 빈 목록을 반환한다") + void getApprovedMemberApplicationsWhenEmpty() throws Exception { + // given + User pendingUser = persist(UserFixture.createUser(university, "대기자", "2021000004")); + persist(ClubApply.of(club, pendingUser, null)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(0))); + } + + @Test + @DisplayName("페이지네이션이 정상 동작한다") + void paginationWorks() throws Exception { + // given - 15명의 승인된 멤버 생성 + for (int i = 1; i <= 15; i++) { + User user = persist(UserFixture.createUser(university, "승인자" + i, "202100" + String.format("%04d", i))); + ClubApply apply = ClubApply.of(club, user, null); + apply.approve(); + persist(apply); + persist(ClubMemberFixture.createMember(club, user)); + } + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then - 첫 페이지 (10개) + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(10))) + .andExpect(jsonPath("$.totalCount").value(15)); + + // 두 페이지 (5개) + performGet("/clubs/" + club.getId() + "/member-applications?page=2&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(5))); + } + + @Test + @DisplayName("권한 없는 사용자는 조회할 수 없다") + void getApprovedMemberApplicationsWithoutPermissionFails() throws Exception { + // given + User regularMember = persist(UserFixture.createUser(university, "일반회원", "2021000005")); + persist(ClubMemberFixture.createMember(club, regularMember)); + clearPersistenceContext(); + + mockLoginUser(regularMember.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("동아리에 속하지 않은 사용자는 조회할 수 없다") + void getApprovedMemberApplicationsByNonMemberFails() throws Exception { + // given + User outsider = persist(UserFixture.createUser(university, "외부인", "2021000006")); + clearPersistenceContext(); + + mockLoginUser(outsider.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /clubs/{clubId}/member-applications/{userId}/answers - 특정 멤버 지원서 답변 조회") + class GetApprovedMemberApplicationAnswers { + + @Test + @DisplayName("승인된 멤버의 지원서 답변을 조회한다") + void getApprovedMemberApplicationAnswersSuccess() throws Exception { + // given + User approvedUser = persist(UserFixture.createUser(university, "승인자", "2021000007")); + + ClubApplyQuestion question1 = persist(ClubApplyQuestion.of(club, "지원 동기", true, 1)); + ClubApplyQuestion question2 = persist(ClubApplyQuestion.of(club, "관심 분야", false, 2)); + + ClubApply approvedApply = ClubApply.of(club, approvedUser, null); + approvedApply.approve(); + persist(approvedApply); + persist(ClubMemberFixture.createMember(club, approvedUser)); + + ClubApplyAnswer answer1 = ClubApplyAnswer.of(approvedApply, question1, "동아리 활동에 관심이 있습니다."); + ClubApplyAnswer answer2 = ClubApplyAnswer.of(approvedApply, question2, "백엔드 개발"); + persist(answer1); + persist(answer2); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(approvedUser.getId())) + .andExpect(jsonPath("$.name").value("승인자")) + .andExpect(jsonPath("$.answers", hasSize(2))) + .andExpect(jsonPath("$.answers[0].question").exists()) + .andExpect(jsonPath("$.answers[0].answer").exists()); + } + + @Test + @DisplayName("질문이 없는 지원서도 조회할 수 있다") + void getApprovedMemberApplicationAnswersWithoutQuestions() throws Exception { + // given + User approvedUser = persist(UserFixture.createUser(university, "질문없음", "2021000008")); + + ClubApply approvedApply = ClubApply.of(club, approvedUser, null); + approvedApply.approve(); + persist(approvedApply); + persist(ClubMemberFixture.createMember(club, approvedUser)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.answers", hasSize(0))); + } + + @Test + @DisplayName("대기중인 지원자의 답변은 조회할 수 없다") + void getPendingMemberApplicationAnswersFails() throws Exception { + // given + User pendingUser = persist(UserFixture.createUser(university, "대기자", "2021000009")); + persist(ClubApply.of(club, pendingUser, null)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + pendingUser.getId() + "/answers") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("동아리 멤버가 아닌 사용자의 답변은 조회할 수 없다") + void getNonMemberApplicationAnswersFails() throws Exception { + // given + User nonMember = persist(UserFixture.createUser(university, "비회원", "2021000010")); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + nonMember.getId() + "/answers") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("시간이 지난 후 수정된 질문은 지원서에 반영되지 않는다") + void questionsModifiedAfterApplicationAreNotShown() throws Exception { + // given + User approvedUser = persist(UserFixture.createUser(university, "과거신청", "2021000011")); + + // 과거에 등록된 질문 + ClubApplyQuestion oldQuestion = ClubApplyQuestion.of(club, "구버전 질문", true, 1); + persist(oldQuestion); + + ClubApply approvedApply = ClubApply.of(club, approvedUser, null); + approvedApply.approve(); + persist(approvedApply); + persist(ClubMemberFixture.createMember(club, approvedUser)); + + ClubApplyAnswer answer = ClubApplyAnswer.of(approvedApply, oldQuestion, "과거 답변"); + persist(answer); + + // 새로운 질문으로 교체 + oldQuestion.softDelete(LocalDateTime.now()); + persist(oldQuestion); + persist(ClubApplyQuestion.of(club, "신규 질문", true, 1)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.answers", hasSize(1))) + .andExpect(jsonPath("$.answers[0].question").value("구버전 질문")); + } + } + + @Nested + @DisplayName("GET /clubs/{clubId}/member-applications/answers - 승인 멤버 지원서 목록 일괄 조회") + class GetApprovedMemberApplicationAnswersList { + + @Test + @DisplayName("승인된 멤버들의 지원서 목록을 일괄 조회한다") + void getApprovedMemberApplicationAnswersListSuccess() throws Exception { + // given + User approvedUser1 = persist(UserFixture.createUser(university, "일괄승인1", "2021000012")); + User approvedUser2 = persist(UserFixture.createUser(university, "일괄승인2", "2021000013")); + + ClubApplyQuestion question = persist(ClubApplyQuestion.of(club, "공통질문", true, 1)); + + ClubApply apply1 = ClubApply.of(club, approvedUser1, null); + ClubApply apply2 = ClubApply.of(club, approvedUser2, null); + apply1.approve(); + apply2.approve(); + persist(apply1); + persist(apply2); + + persist(ClubMemberFixture.createMember(club, approvedUser1)); + persist(ClubMemberFixture.createMember(club, approvedUser2)); + + persist(ClubApplyAnswer.of(apply1, question, "답변1")); + persist(ClubApplyAnswer.of(apply2, question, "답변2")); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/answers?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(2))) + .andExpect(jsonPath("$.applications[0].answers", hasSize(1))) + .andExpect(jsonPath("$.applications[1].answers", hasSize(1))); + } + } +} From 7f80d078dc5ab12a38ec29d71d40059aeb473f54 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:04:10 +0900 Subject: [PATCH 53/55] =?UTF-8?q?fix:=20=EA=B5=AC=EA=B8=80=20Sheet=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EA=B4=80=EB=A0=A8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 구글 시트 403 응답 처리 및 권한 부여 보강 * chore: 구글 시트 체크스타일 보정 * fix: 구글 시트 권한 예외 후속 처리 보완 * fix: 구글 시트 자격 검증 및 실패 감지 보완 * fix: 구글 시트 권한 부여 멱등성 및 Drive 인증 오류 처리 보완 * fix: 구글 시트 권한 정리 기준 및 예외 분류 보완 * fix: 구글 시트 reader 권한 중복 호출 제거 * fix: 시트 invalid_grant 응답 바디 노출 * fix: 시트 권한 처리 리뷰 반영 * chore: 슬랙 리스너 checkstyle 정리 * refactor: 구글 시트 권한 helper 공통화 * fix: 시트 권한 복구 상태 판별 보완 --- .../service/ClubSheetIntegratedService.java | 2 +- .../service/GoogleDrivePermissionHelper.java | 204 +++++++++++++++++- .../GoogleSheetApiExceptionHelper.java | 139 +++++++++++- .../service/GoogleSheetPermissionService.java | 161 ++------------ .../club/service/SheetMigrationService.java | 145 ++++--------- .../konect/global/code/ApiResponseCode.java | 2 + .../global/exception/ErrorResponse.java | 20 +- .../exception/GlobalExceptionHandler.java | 19 +- .../slack/enums/SlackMessageTemplate.java | 10 + .../listener/SheetSyncSlackListener.java | 42 ++++ .../service/SlackNotificationService.java | 13 ++ .../club/service/GoogleApiTestUtils.java | 6 +- .../GoogleDrivePermissionHelperTest.java | 182 ++++++++++++++++ .../GoogleSheetApiExceptionHelperTest.java | 21 +- .../GoogleSheetPermissionServiceTest.java | 93 +++++--- .../club/service/SheetSyncExecutorTest.java | 22 +- .../listener/SheetSyncSlackListenerTest.java | 45 ++++ .../integration/domain/chat/ChatApiTest.java | 14 +- .../club/ClubSheetMigrationApiTest.java | 26 ++- .../domain/user/UserSignupApiTest.java | 34 ++- 20 files changed, 869 insertions(+), 331 deletions(-) create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListener.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java create mode 100644 src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java index 0f02bdb1..8959dc03 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -23,7 +23,7 @@ public SheetImportResponse analyzeAndImportPreMembers( clubPermissionValidator.validateManagerAccess(clubId, requesterId); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); - // Best-effort: OAuth 미연결/권한 부여 실패여도 이미 수동 공유된 시트는 그대로 읽을 수 있다. + // OAuth 미연결이면 건너뛰고 계속 진행한다. Drive 초기화/인증 오류는 예외로 전파한다. googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); SheetHeaderMapper.SheetAnalysisResult analysis = diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java index 8f41fc16..918fb12d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java @@ -1,11 +1,30 @@ package gg.agit.konect.domain.club.service; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; +import lombok.extern.slf4j.Slf4j; + +@Slf4j final class GoogleDrivePermissionHelper { + private static final int PERMISSION_APPLY_MAX_ATTEMPTS = 2; private static final int ROLE_RANK_NONE = 0; private static final int ROLE_RANK_READER = 1; private static final int ROLE_RANK_COMMENTER = 2; private static final int ROLE_RANK_WRITER = 3; + private static final String PERMISSION_FIELDS = + "nextPageToken,permissions(id,type,emailAddress,role)"; + private static final Set SUPPORTED_TARGET_ROLES = Set.of( + "reader", + "commenter", + "writer" + ); private GoogleDrivePermissionHelper() {} @@ -16,7 +35,183 @@ enum PermissionApplyStatus { } static boolean hasRequiredRole(String currentRole, String targetRole) { - return roleRank(currentRole) >= roleRank(targetRole); + return roleRank(currentRole) >= validateTargetRole(targetRole); + } + + static PermissionApplyStatus ensureServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole, + String serviceAccountEmail + ) throws IOException { + validateTargetRole(targetRole); + Permission initialPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + int attempt = 1; + while (true) { + try { + return applyServiceAccountPermission( + userDriveService, + fileId, + targetRole, + serviceAccountEmail + ); + } catch (IOException e) { + PermissionApplyStatus recoveredStatus = recoverPermissionApplyStatus( + userDriveService, + fileId, + serviceAccountEmail, + targetRole, + initialPermission, + attempt + ); + if (recoveredStatus != null) { + log.info( + "Service account permission reached target role after attempt {}. " + + "fileId={}, role={}, email={}, status={}", + attempt, + fileId, + targetRole, + serviceAccountEmail, + recoveredStatus + ); + return recoveredStatus; + } + + if (attempt++ >= PERMISSION_APPLY_MAX_ATTEMPTS) { + throw e; + } + } + } + } + + static List listAllPermissions(Drive driveService, String fileId) throws IOException { + List permissions = new ArrayList<>(); + String nextPageToken = null; + + do { + Drive.Permissions.List request = driveService.permissions().list(fileId) + .setFields(PERMISSION_FIELDS); + if (nextPageToken != null) { + request.setPageToken(nextPageToken); + } + + PermissionList response = request.execute(); + if (response.getPermissions() != null) { + permissions.addAll(response.getPermissions()); + } + nextPageToken = response.getNextPageToken(); + } while (nextPageToken != null && !nextPageToken.isBlank()); + + return permissions; + } + + static Permission findServiceAccountPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail + ) throws IOException { + return listAllPermissions(userDriveService, fileId).stream() + .filter(permission -> "user".equals(permission.getType())) + .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) + .findFirst() + .orElse(null); + } + + private static PermissionApplyStatus applyServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole, + String serviceAccountEmail + ) throws IOException { + validateTargetRole(targetRole); + Permission existingPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + + if (existingPermission == null) { + Permission permission = new Permission() + .setType("user") + .setRole(targetRole) + .setEmailAddress(serviceAccountEmail); + + userDriveService.permissions().create(fileId, permission) + .setSendNotificationEmail(false) + .execute(); + log.info( + "Service account {} access granted. fileId={}, email={}", + targetRole, + fileId, + serviceAccountEmail + ); + return PermissionApplyStatus.CREATED; + } + + String currentRole = existingPermission.getRole(); + if (hasRequiredRole(currentRole, targetRole)) { + log.info( + "Service account permission already satisfies requested role. fileId={}, role={}, email={}", + fileId, + currentRole, + serviceAccountEmail + ); + return PermissionApplyStatus.UNCHANGED; + } + + Permission updatedPermission = new Permission().setRole(targetRole); + userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .execute(); + log.info( + "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", + fileId, + currentRole, + targetRole, + serviceAccountEmail + ); + return PermissionApplyStatus.UPGRADED; + } + + private static PermissionApplyStatus recoverPermissionApplyStatus( + Drive userDriveService, + String fileId, + String serviceAccountEmail, + String targetRole, + Permission initialPermission, + int attempt + ) { + try { + Permission currentPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + if (currentPermission == null + || !hasRequiredRole(currentPermission.getRole(), targetRole)) { + return null; + } + + if (initialPermission == null) { + return PermissionApplyStatus.CREATED; + } + + return hasRequiredRole(initialPermission.getRole(), targetRole) + ? PermissionApplyStatus.UNCHANGED + : PermissionApplyStatus.UPGRADED; + } catch (IOException e) { + log.debug( + "Failed to re-check service account permission after attempt {}. fileId={}, email={}, cause={}", + attempt, + fileId, + serviceAccountEmail, + e.getMessage() + ); + return null; + } } private static int roleRank(String role) { @@ -31,4 +226,11 @@ private static int roleRank(String role) { default -> ROLE_RANK_NONE; }; } + + private static int validateTargetRole(String targetRole) { + if (!SUPPORTED_TARGET_ROLES.contains(targetRole)) { + throw new IllegalArgumentException("Unsupported targetRole: " + targetRole); + } + return roleRank(targetRole); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java index e7a650ea..d289429d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java @@ -3,6 +3,8 @@ import java.io.IOException; import java.util.List; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import com.google.api.client.googleapis.json.GoogleJsonError; import com.google.api.client.googleapis.json.GoogleJsonResponseException; @@ -26,6 +28,12 @@ public final class GoogleSheetApiExceptionHelper { "invalidCredentials", "unauthorized" ); + private static final String INVALID_GRANT_ERROR = "invalid_grant"; + private static final Pattern ERROR_FIELD_PATTERN = + Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern ERROR_DESCRIPTION_PATTERN = + Pattern.compile("\"error_description\"\\s*:\\s*\"([^\"]+)\""); + private static final int HTTP_STATUS_BAD_REQUEST = 400; private static final int HTTP_STATUS_FORBIDDEN = 403; private static final int HTTP_STATUS_UNAUTHORIZED = 401; private static final int HTTP_STATUS_NOT_FOUND = 404; @@ -57,10 +65,57 @@ public static boolean isNotFound(IOException exception) { return getStatusCode(exception) == HTTP_STATUS_NOT_FOUND; } + public static boolean isInvalidGrant(IOException exception) { + if (getStatusCode(exception) != HTTP_STATUS_BAD_REQUEST) { + return false; + } + + String content = getResponseContent(exception); + if (content == null) { + return false; + } + + return content.toLowerCase().contains(INVALID_GRANT_ERROR); + } + public static CustomException accessDenied() { return CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); } + public static CustomException invalidGoogleDriveAuth(IOException exception) { + return CustomException.of( + ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH, + extractClientDetail(exception) + ); + } + + public static String extractDetail(IOException exception) { + HttpResponseException responseException = findResponseException(exception); + if (responseException != null) { + String content = responseException.getContent(); + if (content != null && !content.isBlank()) { + return "%d %s%n%s".formatted( + responseException.getStatusCode(), + defaultStatusText(responseException.getStatusCode()), + content + ); + } + + String message = responseException.getMessage(); + if (message != null && !message.isBlank()) { + return message; + } + } + return exception.getMessage(); + } + + private static String extractClientDetail(IOException exception) { + if (isInvalidGrant(exception)) { + return sanitizeInvalidGrantDetail(exception); + } + return extractDetail(exception); + } + private static boolean hasReason( GoogleJsonResponseException exception, Set expectedReasons @@ -84,12 +139,88 @@ private static List getReasons(GoogleJsonResponseException exception) { } private static int getStatusCode(IOException exception) { - if (exception instanceof GoogleJsonResponseException responseException) { - return responseException.getStatusCode(); - } - if (exception instanceof HttpResponseException responseException) { + HttpResponseException responseException = findResponseException(exception); + if (responseException != null) { return responseException.getStatusCode(); } return -1; } + + private static String getResponseContent(IOException exception) { + HttpResponseException responseException = findResponseException(exception); + if (responseException == null) { + return null; + } + + String content = responseException.getContent(); + if (content != null && !content.isBlank()) { + return content; + } + + return responseException.getMessage(); + } + + private static HttpResponseException findResponseException(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof GoogleJsonResponseException responseException) { + return responseException; + } + if (current instanceof HttpResponseException responseException) { + return responseException; + } + current = current.getCause(); + } + return null; + } + + private static String sanitizeInvalidGrantDetail(IOException exception) { + String content = getResponseContent(exception); + if (content == null) { + return "%d %s%nerror=%s".formatted( + HTTP_STATUS_BAD_REQUEST, + defaultStatusText(HTTP_STATUS_BAD_REQUEST), + INVALID_GRANT_ERROR + ); + } + + String error = extractJsonField(content, ERROR_FIELD_PATTERN); + String errorDescription = extractJsonField(content, ERROR_DESCRIPTION_PATTERN); + + StringBuilder detail = new StringBuilder() + .append(HTTP_STATUS_BAD_REQUEST) + .append(' ') + .append(defaultStatusText(HTTP_STATUS_BAD_REQUEST)); + if (error != null) { + detail.append(System.lineSeparator()).append("error=").append(error); + } + if (errorDescription != null) { + detail.append(System.lineSeparator()) + .append("error_description=") + .append(errorDescription); + } + + if (error == null && errorDescription == null) { + detail.append(System.lineSeparator()).append("error=").append(INVALID_GRANT_ERROR); + } + return detail.toString(); + } + + private static String extractJsonField(String content, Pattern pattern) { + Matcher matcher = pattern.matcher(content); + if (!matcher.find()) { + return null; + } + return matcher.group(1).trim(); + } + + private static String defaultStatusText(int statusCode) { + return switch (statusCode) { + case HTTP_STATUS_BAD_REQUEST -> "Bad Request"; + case HTTP_STATUS_UNAUTHORIZED -> "Unauthorized"; + case HTTP_STATUS_FORBIDDEN -> "Forbidden"; + case HTTP_STATUS_NOT_FOUND -> "Not Found"; + default -> "HTTP Error"; + }; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java index 33906432..c01c588a 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java @@ -2,13 +2,11 @@ import java.io.IOException; import java.security.GeneralSecurityException; -import java.util.List; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import com.google.api.services.drive.Drive; -import com.google.api.services.drive.model.Permission; import com.google.auth.oauth2.ServiceAccountCredentials; import gg.agit.konect.domain.user.enums.Provider; @@ -24,8 +22,6 @@ @RequiredArgsConstructor public class GoogleSheetPermissionService { - private static final int PERMISSION_APPLY_MAX_ATTEMPTS = 2; - private final ServiceAccountCredentials serviceAccountCredentials; private final GoogleSheetsConfig googleSheetsConfig; private final UserOAuthAccountRepository userOAuthAccountRepository; @@ -37,7 +33,7 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp .filter(StringUtils::hasText) .orElse(null); - if (!StringUtils.hasText(refreshToken)) { + if (refreshToken == null) { log.warn( "Skipping service account auto-share because Google Drive OAuth is not connected. requesterId={}", requesterId @@ -54,9 +50,25 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp } try { - ensureServiceAccountPermission(userDriveService, spreadsheetId, "writer"); + GoogleDrivePermissionHelper.ensureServiceAccountPermission( + userDriveService, + spreadsheetId, + "writer", + getServiceAccountEmail() + ); return true; } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while auto-sharing spreadsheet. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) || GoogleSheetApiExceptionHelper.isAuthFailure(e) || GoogleSheetApiExceptionHelper.isNotFound(e)) { @@ -79,143 +91,6 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp } } - private void ensureServiceAccountPermission( - Drive userDriveService, - String fileId, - String targetRole - ) throws IOException { - String serviceAccountEmail = getServiceAccountEmail(); - - for (int attempt = 1; attempt <= PERMISSION_APPLY_MAX_ATTEMPTS; attempt++) { - try { - applyServiceAccountPermission( - userDriveService, - fileId, - targetRole, - serviceAccountEmail - ); - return; - } catch (IOException e) { - if (hasRequiredPermission( - userDriveService, - fileId, - serviceAccountEmail, - targetRole - )) { - log.info( - "Service account permission reached target role after retry. fileId={}, role={}, email={}", - fileId, - targetRole, - serviceAccountEmail - ); - return; - } - - if (attempt == PERMISSION_APPLY_MAX_ATTEMPTS) { - throw e; - } - } - } - } - - private void applyServiceAccountPermission( - Drive userDriveService, - String fileId, - String targetRole, - String serviceAccountEmail - ) throws IOException { - Permission existingPermission = findServiceAccountPermission( - userDriveService, - fileId, - serviceAccountEmail - ); - - if (existingPermission == null) { - Permission permission = new Permission() - .setType("user") - .setRole(targetRole) - .setEmailAddress(serviceAccountEmail); - - userDriveService.permissions().create(fileId, permission) - .setSendNotificationEmail(false) - .execute(); - log.info( - "Service account access granted. fileId={}, role={}, email={}", - fileId, - targetRole, - serviceAccountEmail - ); - return; - } - - String currentRole = existingPermission.getRole(); - if (GoogleDrivePermissionHelper.hasRequiredRole(currentRole, targetRole)) { - log.info( - "Service account permission already satisfies requested role. fileId={}, role={}, email={}", - fileId, - currentRole, - serviceAccountEmail - ); - return; - } - - Permission updatedPermission = new Permission().setRole(targetRole); - userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) - .execute(); - log.info( - "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", - fileId, - currentRole, - targetRole, - serviceAccountEmail - ); - } - - private boolean hasRequiredPermission( - Drive userDriveService, - String fileId, - String serviceAccountEmail, - String targetRole - ) { - try { - Permission currentPermission = findServiceAccountPermission( - userDriveService, - fileId, - serviceAccountEmail - ); - return currentPermission != null - && GoogleDrivePermissionHelper.hasRequiredRole(currentPermission.getRole(), targetRole); - } catch (IOException e) { - log.debug( - "Failed to re-check service account permission. fileId={}, email={}, cause={}", - fileId, - serviceAccountEmail, - e.getMessage() - ); - return false; - } - } - - private Permission findServiceAccountPermission( - Drive userDriveService, - String fileId, - String serviceAccountEmail - ) throws IOException { - List permissions = userDriveService.permissions().list(fileId) - .setFields("permissions(id,emailAddress,role)") - .execute() - .getPermissions(); - - if (permissions == null) { - return null; - } - - return permissions.stream() - .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) - .findFirst() - .orElse(null); - } - private String getServiceAccountEmail() { return serviceAccountCredentials.getClientEmail(); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index 616d3873..1eeb66c2 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -166,32 +166,27 @@ public void afterCompletion(int status) { private void removeServiceAccountPermission(Drive driveService, String fileId) { String serviceAccountEmail = serviceAccountCredentials.getClientEmail(); try { - // permissionId 조회 후 삭제 (getPermissions()는 빈 경우 null 반환 가능) - List permissions = - driveService.permissions().list(fileId) - .setFields("permissions(id,emailAddress)") - .execute() - .getPermissions(); - if (permissions == null) { + Permission permission = GoogleDrivePermissionHelper.findServiceAccountPermission( + driveService, + fileId, + serviceAccountEmail + ); + if (permission == null) { return; } - permissions.stream() - .filter(p -> serviceAccountEmail.equals(p.getEmailAddress())) - .findFirst() - .ifPresent(p -> { - try { - driveService.permissions().delete(fileId, p.getId()).execute(); - log.info( - "Service account permission removed from source file. fileId={}", - fileId - ); - } catch (IOException ex) { - log.warn( - "Failed to remove service account permission. fileId={}, cause={}", - fileId, ex.getMessage() - ); - } - }); + + try { + driveService.permissions().delete(fileId, permission.getId()).execute(); + log.info( + "Service account permission removed from source file. fileId={}", + fileId + ); + } catch (IOException ex) { + log.warn( + "Failed to remove service account permission. fileId={}, cause={}", + fileId, ex.getMessage() + ); + } } catch (IOException e) { log.warn( "Failed to list permissions for source file cleanup. fileId={}, cause={}", @@ -213,8 +208,23 @@ private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountPer String role ) { try { - return ensureServiceAccountPermission(userDriveService, fileId, role); + return GoogleDrivePermissionHelper.ensureServiceAccountPermission( + userDriveService, + fileId, + role, + serviceAccountCredentials.getClientEmail() + ); } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { log.warn( "Google Drive auth failed while granting service account permission. " @@ -243,80 +253,6 @@ private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountPer } } - private GoogleDrivePermissionHelper.PermissionApplyStatus ensureServiceAccountPermission( - Drive userDriveService, - String fileId, - String targetRole - ) throws IOException { - String serviceAccountEmail = serviceAccountCredentials.getClientEmail(); - Permission existingPermission = findServiceAccountPermission( - userDriveService, - fileId, - serviceAccountEmail - ); - - if (existingPermission == null) { - Permission permission = new Permission() - .setType("user") - .setRole(targetRole) - .setEmailAddress(serviceAccountEmail); - userDriveService.permissions().create(fileId, permission) - .setSendNotificationEmail(false) - .execute(); - log.info( - "Service account {} access granted. fileId={}, email={}", - targetRole, - fileId, - serviceAccountEmail - ); - return GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED; - } - - String currentRole = existingPermission.getRole(); - if (GoogleDrivePermissionHelper.hasRequiredRole(currentRole, targetRole)) { - log.info( - "Service account permission already satisfies requested role. fileId={}, role={}, email={}", - fileId, - currentRole, - serviceAccountEmail - ); - return GoogleDrivePermissionHelper.PermissionApplyStatus.UNCHANGED; - } - - Permission updatedPermission = new Permission().setRole(targetRole); - userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) - .execute(); - log.info( - "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", - fileId, - currentRole, - targetRole, - serviceAccountEmail - ); - return GoogleDrivePermissionHelper.PermissionApplyStatus.UPGRADED; - } - - private Permission findServiceAccountPermission( - Drive userDriveService, - String fileId, - String serviceAccountEmail - ) throws IOException { - List permissions = userDriveService.permissions().list(fileId) - .setFields("permissions(id,type,emailAddress,role)") - .execute() - .getPermissions(); - - if (permissions == null) { - return null; - } - - return permissions.stream() - .filter(permission -> "user".equals(permission.getType())) - .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) - .findFirst() - .orElse(null); - } - private void registerDriveRollback(Drive driveService, String fileId) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override @@ -414,6 +350,16 @@ private String copyTemplate(Drive driveService, String templateId, String clubNa if (newFileId != null) { deleteFile(driveService, newFileId); } + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while copying template. templateId={}, " + + "targetFolderId={}, cause={}", + templateId, + targetFolderId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { log.warn( "Google Drive auth failed while copying template. templateId={}, targetFolderId={}, cause={}", @@ -442,6 +388,7 @@ private List> readAllData( SheetColumnMapping mapping ) { try { + // 이 호출은 서비스 계정 Sheets API를 사용하므로 user OAuth refresh token 기반 invalid_grant는 발생하지 않는다. int dataStartRow = mapping.getDataStartRow(); String range = "A" + dataStartRow + ":Z"; ValueRange response = googleSheetsService.spreadsheets().values() diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 67e6dea5..d62a0fdf 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -18,6 +18,8 @@ public enum ApiResponseCode { MISSING_REQUIRED_PARAMETER(HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."), FAILED_EXTRACT_EMAIL(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 이메일 정보를 가져올 수 없습니다."), FAILED_EXTRACT_PROVIDER_ID(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 제공자 식별자를 가져올 수 없습니다."), + INVALID_GOOGLE_DRIVE_AUTH(HttpStatus.BAD_REQUEST, + "Google Drive 인증이 만료되었거나 올바르지 않습니다. Drive 권한을 다시 연결해 주세요."), CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), CANNOT_LEAVE_GROUP_CHAT_ROOM(HttpStatus.BAD_REQUEST, "동아리 채팅방은 나갈 수 없습니다."), CANNOT_KICK_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 강퇴할 수 없습니다."), diff --git a/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java b/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java index f0a72d2b..df5af21c 100644 --- a/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java +++ b/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java @@ -17,13 +17,30 @@ public record ErrorResponse( @Schema(description = "에러 추적용 UUID") String errorTraceId, + @Schema(description = "에러 상세 사유") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + String detail, + @Schema(description = "필드별 검증 오류 목록") @JsonInclude(JsonInclude.Include.NON_EMPTY) List fieldErrors ) { public ErrorResponse(String code, String message, String errorTraceId) { - this(code, message, errorTraceId, List.of()); + this(code, message, errorTraceId, null, List.of()); + } + + public ErrorResponse( + String code, + String message, + String errorTraceId, + List fieldErrors + ) { + this(code, message, errorTraceId, null, fieldErrors); + } + + public ErrorResponse(String code, String message, String errorTraceId, String detail) { + this(code, message, errorTraceId, detail, List.of()); } @Schema(description = "필드별 검증 오류 목록 아이템") @@ -44,4 +61,3 @@ public FieldError(String field, String message, String constraint) { } } } - diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index 64f13080..f0df6e53 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -47,7 +47,12 @@ public ResponseEntity handleCustomException( HttpServletRequest request, CustomException e ) { - return buildErrorResponse(request, e.getErrorCode(), e.getFullMessage()); + return buildErrorResponse( + request, + e.getErrorCode(), + e.getFullMessage(), + e.getDetail() + ); } @ExceptionHandler(IllegalArgumentException.class) @@ -218,6 +223,15 @@ private ResponseEntity buildErrorResponse( HttpServletRequest request, ApiResponseCode errorCode, String errorMessage + ) { + return buildErrorResponse(request, errorCode, errorMessage, null); + } + + private ResponseEntity buildErrorResponse( + HttpServletRequest request, + ApiResponseCode errorCode, + String errorMessage, + String detail ) { String errorTraceId = UUID.randomUUID().toString(); requestLogging(request, errorCode.getHttpStatus().value(), errorMessage, errorTraceId); @@ -225,7 +239,8 @@ private ResponseEntity buildErrorResponse( ErrorResponse response = new ErrorResponse( errorCode.getCode(), errorCode.getMessage(), - errorTraceId + errorTraceId, + detail ); return ResponseEntity.status(errorCode.getHttpStatus().value()).body(response); diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java index c329dbf8..76711b27 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java @@ -28,6 +28,16 @@ public enum SlackMessageTemplate { > %s """ ), + SHEET_SYNC_FAILED( + """ + *:warning: 시트 동기화 실패* + 동아리 ID: %s + 스프레드시트 ID: `%s` + 유형: %s + 발생 시각: %s + > %s + """ + ), ; private final String template; diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListener.java new file mode 100644 index 00000000..f63e4008 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListener.java @@ -0,0 +1,42 @@ +package gg.agit.konect.infrastructure.slack.listener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncSlackListener { + + private final SlackNotificationService slackNotificationService; + + @TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true) + public void handleSheetSyncFailed(SheetSyncFailedEvent event) { + try { + log.warn( + "Handling sheet sync failure event. occurredAt={}, clubId={}, spreadsheetId={}, " + + "accessDenied={}, reason={}", + event.occurredAt(), + event.clubId(), + event.spreadsheetId(), + event.accessDenied(), + event.reason() + ); + slackNotificationService.notifySheetSyncFailed(event); + } catch (Exception e) { + log.error( + "Failed to handle sheet sync failure event. clubId={}, spreadsheetId={}", + event.clubId(), + event.spreadsheetId(), + e + ); + } + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java index 87703a20..86db2f7d 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java @@ -2,11 +2,13 @@ import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.ADMIN_CHAT_RECEIVED; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.INQUIRY; +import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.SHEET_SYNC_FAILED; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_REGISTER; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_WITHDRAWAL; import org.springframework.stereotype.Service; +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.infrastructure.slack.client.SlackClient; import gg.agit.konect.infrastructure.slack.config.SlackProperties; import lombok.RequiredArgsConstructor; @@ -37,4 +39,15 @@ public void notifyAdminChatReceived(String senderName, String content) { String message = ADMIN_CHAT_RECEIVED.format(senderName, content); slackClient.sendMessage(message, slackProperties.webhooks().event()); } + + public void notifySheetSyncFailed(SheetSyncFailedEvent event) { + String message = SHEET_SYNC_FAILED.format( + event.clubId(), + event.spreadsheetId(), + event.accessDenied() ? "ACCESS_DENIED" : "UNEXPECTED", + event.occurredAt(), + event.reason() + ); + slackClient.sendMessage(message, slackProperties.webhooks().error()); + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java index 8cb42187..2c2603cb 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java @@ -29,10 +29,14 @@ static GoogleJsonResponseException googleException(int statusCode, String reason } static HttpResponseException httpResponseException(int statusCode) { + return httpResponseException(statusCode, null); + } + + static HttpResponseException httpResponseException(int statusCode, String content) { return new HttpResponseException.Builder( statusCode, null, new HttpHeaders() - ).build(); + ).setContent(content).build(); } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java new file mode 100644 index 00000000..cd5337d3 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java @@ -0,0 +1,182 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; + +import gg.agit.konect.support.ServiceTestSupport; + +class GoogleDrivePermissionHelperTest extends ServiceTestSupport { + + private static final String FILE_ID = "spreadsheet-id"; + + @Mock + private Drive driveService; + + @Mock + private Drive.Permissions permissions; + + @Mock + private Drive.Permissions.List firstListRequest; + + @Mock + private Drive.Permissions.List secondListRequest; + + @Mock + private Drive.Permissions.Create createRequest; + + @Mock + private Drive.Permissions.Update updateRequest; + + @Test + @DisplayName("returns true only when the current role satisfies the target role") + void hasRequiredRoleReturnsExpectedResult() { + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("reader", "reader")).isTrue(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("commenter", "reader")).isTrue(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("writer", "commenter")).isTrue(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("reader", "commenter")).isFalse(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("commenter", "writer")).isFalse(); + } + + @Test + @DisplayName("throws when target role is unsupported") + void hasRequiredRoleThrowsWhenTargetRoleIsUnsupported() { + assertThatThrownBy(() -> GoogleDrivePermissionHelper.hasRequiredRole("reader", "invalid-role")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported targetRole"); + } + + @Test + @DisplayName("lists all permissions across paged Drive responses") + void listAllPermissionsReturnsPermissionsAcrossPages() throws IOException { + Permission firstPermission = new Permission().setId("perm-1"); + Permission secondPermission = new Permission().setId("perm-2"); + + given(driveService.permissions()).willReturn(permissions); + given(permissions.list(FILE_ID)).willReturn(firstListRequest, secondListRequest); + given(firstListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(firstListRequest); + given(firstListRequest.execute()).willReturn( + new PermissionList() + .setPermissions(List.of(firstPermission)) + .setNextPageToken("next-page") + ); + given(secondListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(secondListRequest); + given(secondListRequest.setPageToken("next-page")).willReturn(secondListRequest); + given(secondListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(secondPermission)) + ); + + assertThat(GoogleDrivePermissionHelper.listAllPermissions(driveService, FILE_ID)) + .containsExactly(firstPermission, secondPermission); + } + + @Test + @DisplayName("returns created when create succeeds after a retry check") + void ensureServiceAccountPermissionReturnsCreatedWhenPermissionAppearsAfterRetry() throws IOException { + Drive.Permissions.List initialListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List applyListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List recheckListRequest = mock(Drive.Permissions.List.class); + + given(driveService.permissions()).willReturn(permissions); + given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); + given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(initialListRequest); + given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(applyListRequest); + given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(recheckListRequest); + given(initialListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); + given(applyListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); + given(recheckListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "writer"))) + ); + given(permissions.create(eq(FILE_ID), org.mockito.ArgumentMatchers.any(Permission.class))) + .willReturn(createRequest); + given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.execute()).willThrow(new IOException("create failed after applying")); + + assertThat( + GoogleDrivePermissionHelper.ensureServiceAccountPermission( + driveService, + FILE_ID, + "writer", + "service-account@project.iam.gserviceaccount.com" + ) + ).isEqualTo(GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED); + } + + @Test + @DisplayName("returns upgraded when update succeeds after a retry check") + void ensureServiceAccountPermissionReturnsUpgradedWhenPermissionImprovesAfterRetry() throws IOException { + Drive.Permissions.List initialListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List applyListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List recheckListRequest = mock(Drive.Permissions.List.class); + + given(driveService.permissions()).willReturn(permissions); + given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); + given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(initialListRequest); + given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(applyListRequest); + given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(recheckListRequest); + given(initialListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "reader"))) + ); + given(applyListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "reader"))) + ); + given(recheckListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "writer"))) + ); + given(permissions.update(eq(FILE_ID), eq("perm-1"), org.mockito.ArgumentMatchers.any(Permission.class))) + .willReturn(updateRequest); + given(updateRequest.execute()).willThrow(new IOException("update failed after applying")); + + assertThat( + GoogleDrivePermissionHelper.ensureServiceAccountPermission( + driveService, + FILE_ID, + "writer", + "service-account@project.iam.gserviceaccount.com" + ) + ).isEqualTo(GoogleDrivePermissionHelper.PermissionApplyStatus.UPGRADED); + } + + @Test + @DisplayName("throws when ensure is called with unsupported target role") + void ensureServiceAccountPermissionThrowsWhenTargetRoleIsUnsupported() { + assertThatThrownBy( + () -> GoogleDrivePermissionHelper.ensureServiceAccountPermission( + driveService, + FILE_ID, + "invalid-role", + "service-account@project.iam.gserviceaccount.com" + ) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported targetRole"); + } + + private Permission serviceAccountPermission(String permissionId, String role) { + return new Permission() + .setId(permissionId) + .setType("user") + .setEmailAddress("service-account@project.iam.gserviceaccount.com") + .setRole(role); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java index 644b6da7..75ec30be 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java @@ -1,9 +1,10 @@ package gg.agit.konect.domain.club.service; -import static org.assertj.core.api.Assertions.assertThat; import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; +import static org.assertj.core.api.Assertions.assertThat; +import java.io.IOException; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -70,6 +71,24 @@ void isNotFoundReturnsTrueFor404() { assertThat(GoogleSheetApiExceptionHelper.isNotFound(notFound)).isTrue(); } + @Test + @DisplayName("classifies nested 400 invalid_grant responses as invalid Google Drive auth") + void isInvalidGrantReturnsTrueForNestedHttpResponseException() { + IOException wrapped = new IOException( + "token refresh failed", + httpResponseException(400, "{\"error\":\"invalid_grant\",\"error_description\":\"Bad Request\"}") + ); + + assertThat(GoogleSheetApiExceptionHelper.isInvalidGrant(wrapped)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.extractDetail(wrapped)) + .contains("400 Bad Request") + .contains("invalid_grant"); + assertThat(GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(wrapped).getDetail()) + .contains("400 Bad Request") + .contains("error=invalid_grant") + .contains("error_description=Bad Request"); + } + @Test @DisplayName("classifies non-json http response exceptions with their status code") void classifiesNonJsonHttpResponseExceptionByStatusCode() { diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java index 552aa895..0db70d9e 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -1,7 +1,9 @@ package gg.agit.konect.domain.club.service; -import static org.assertj.core.api.Assertions.assertThat; import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; +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.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -26,6 +28,8 @@ import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; import gg.agit.konect.support.ServiceTestSupport; @@ -57,6 +61,9 @@ class GoogleSheetPermissionServiceTest extends ServiceTestSupport { @Mock private Drive.Permissions.List listRequest; + @Mock + private Drive.Permissions.List nextPageListRequest; + @Mock private Drive.Permissions.Create createRequest; @@ -69,17 +76,14 @@ class GoogleSheetPermissionServiceTest extends ServiceTestSupport { @Test @DisplayName("returns false when the requester has no Google Drive OAuth account") void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { - // given given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) .willReturn(Optional.empty()); - // when boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( REQUESTER_ID, FILE_ID ); - // then assertThat(granted).isFalse(); } @@ -87,32 +91,55 @@ void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { @DisplayName("returns true without creating when the service account already has writer access") void tryGrantServiceAccountWriterAccessReturnsTrueWhenPermissionAlreadyExists() throws IOException, GeneralSecurityException { - // given mockConnectedDriveAccount(); given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); - // when boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( REQUESTER_ID, FILE_ID ); - // then assertThat(granted).isTrue(); verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); verify(permissions, never()).update(eq(FILE_ID), eq("perm-1"), any(Permission.class)); } + @Test + @DisplayName("finds existing permission across paged Drive permission results") + void tryGrantServiceAccountWriterAccessFindsPermissionAcrossPages() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest, nextPageListRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of()).setNextPageToken("next-page") + ); + given(nextPageListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(nextPageListRequest); + given(nextPageListRequest.setPageToken("next-page")).willReturn(nextPageListRequest); + given(nextPageListRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + } + @Test @DisplayName("returns true when create fails but the permission is visible on re-check") void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() throws IOException, GeneralSecurityException { - // given mockConnectedDriveAccount(); given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); given(listRequest.execute()).willReturn( permissionList(), permissionList(permission("perm-1", "writer")) @@ -121,13 +148,11 @@ void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); given(createRequest.execute()).willThrow(new IOException("already granted")); - // when boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( REQUESTER_ID, FILE_ID ); - // then assertThat(granted).isTrue(); verify(permissions).create(eq(FILE_ID), any(Permission.class)); } @@ -136,21 +161,19 @@ void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() @DisplayName("returns true when an existing permission needs to be upgraded to writer") void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() throws IOException, GeneralSecurityException { - // given mockConnectedDriveAccount(); given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); given(listRequest.execute()).willReturn(permissionList(permission("perm-x", "reader"))); given(permissions.update(eq(FILE_ID), eq("perm-x"), any(Permission.class))).willReturn(updateRequest); given(updateRequest.execute()).willReturn(permission("perm-x", "writer")); - // when boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( REQUESTER_ID, FILE_ID ); - // then assertThat(granted).isTrue(); verify(permissions).update(eq(FILE_ID), eq("perm-x"), any(Permission.class)); } @@ -159,19 +182,17 @@ void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() @DisplayName("returns false when Google Drive auth fails during permission lookup") void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() throws IOException, GeneralSecurityException { - // given mockConnectedDriveAccount(); given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); given(listRequest.execute()).willThrow(googleException(401, "authError")); - // when boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( REQUESTER_ID, FILE_ID ); - // then assertThat(granted).isFalse(); } @@ -179,22 +200,45 @@ void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() @DisplayName("returns false when Google Drive reports access denied while listing permissions") void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() throws IOException, GeneralSecurityException { - // given mockConnectedDriveAccount(); given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("permissions(id,emailAddress,role)")).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); given(listRequest.execute()).willThrow(googleException(403, "forbidden")); - // when boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( REQUESTER_ID, FILE_ID ); - // then assertThat(granted).isFalse(); } + @Test + @DisplayName("throws a bad request custom exception when Google returns invalid_grant") + void tryGrantServiceAccountWriterAccessThrowsWhenInvalidGrantOccurs() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willThrow(new IOException( + "token refresh failed", + httpResponseException( + 400, + "{\"error\":\"invalid_grant\",\"error_description\":\"Bad Request\"}" + ) + )); + + assertThatThrownBy(() -> googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + )) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH); + } + private void mockConnectedDriveAccount() throws IOException, GeneralSecurityException { given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) .willReturn(Optional.of(userOAuthAccount)); @@ -215,5 +259,4 @@ private Permission permission(String id, String role) { private PermissionList permissionList(Permission... permissions) { return new PermissionList().setPermissions(List.of(permissions)); } - } diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java index 1ac3d45f..92aea1bf 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; import java.util.List; @@ -15,10 +16,6 @@ import org.springframework.context.ApplicationEventPublisher; import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.api.client.googleapis.json.GoogleJsonError; -import com.google.api.client.googleapis.json.GoogleJsonResponseException; -import com.google.api.client.http.HttpHeaders; -import com.google.api.client.http.HttpResponseException; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ClearValuesRequest; @@ -88,21 +85,4 @@ void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { && sheetSyncFailedEvent.accessDenied() )); } - - private GoogleJsonResponseException googleException(int statusCode, String reason) { - GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); - errorInfo.setReason(reason); - - GoogleJsonError error = new GoogleJsonError(); - error.setCode(statusCode); - error.setErrors(List.of(errorInfo)); - - HttpResponseException.Builder builder = new HttpResponseException.Builder( - statusCode, - null, - new HttpHeaders() - ); - - return new GoogleJsonResponseException(builder, error); - } } diff --git a/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java b/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java new file mode 100644 index 00000000..4da3a216 --- /dev/null +++ b/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java @@ -0,0 +1,45 @@ +package gg.agit.konect.infrastructure.slack.listener; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import gg.agit.konect.support.ServiceTestSupport; + +class SheetSyncSlackListenerTest extends ServiceTestSupport { + + @Mock + private SlackNotificationService slackNotificationService; + + @InjectMocks + private SheetSyncSlackListener sheetSyncSlackListener; + + @Test + @DisplayName("delegates sheet sync failure events to Slack notification service") + void handleSheetSyncFailedDelegatesToSlackService() { + SheetSyncFailedEvent event = SheetSyncFailedEvent.accessDenied(1, "spreadsheet-id", "access denied"); + + sheetSyncSlackListener.handleSheetSyncFailed(event); + + verify(slackNotificationService).notifySheetSyncFailed(event); + } + + @Test + @DisplayName("swallows listener exceptions so event publishing flow is not broken") + void handleSheetSyncFailedSwallowsExceptions() { + SheetSyncFailedEvent event = SheetSyncFailedEvent.unexpected(1, "spreadsheet-id", "boom"); + doThrow(new RuntimeException("slack error")) + .when(slackNotificationService) + .notifySheetSyncFailed(event); + + sheetSyncSlackListener.handleSheetSyncFailed(event); + + verify(slackNotificationService).notifySheetSyncFailed(event); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index e31174f5..6b988082 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -243,8 +243,8 @@ void setUpInvitableUsersFixture() { .clubPosition(ClubPosition.MEMBER) .build()); - ChatRoom bcsdRoom = persist(ChatRoom.groupOf(bcsd)); - ChatRoom cseRoom = persist(ChatRoom.groupOf(cse)); + ChatRoom bcsdRoom = persist(ChatRoom.clubGroupOf(bcsd)); + ChatRoom cseRoom = persist(ChatRoom.clubGroupOf(cse)); createDirectChatRoom(normalUser, directOnlyUser); createDirectChatRoom(normalUser, adminCandidate); @@ -658,7 +658,7 @@ void cannotOperateHiddenDirectRoomBeforeNewMessage() throws Exception { @DisplayName("동아리 채팅방은 나갈 수 없다") void leaveGroupChatRoomFails() throws Exception { Club club = persist(ClubFixture.create(university)); - ChatRoom groupRoom = persist(ChatRoom.groupOf(club)); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); ChatRoom managedGroupRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); persist(ChatRoomMember.of(managedGroupRoom, managedNormalUser, groupRoom.getCreatedAt())); @@ -754,7 +754,7 @@ void setUpSearchFixture() { void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { // given ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); - ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(developmentClub)); addRoomMember(groupRoom, normalUser); persistChatMessage(directRoom, normalUser, "안녕하세요"); mockLoginUser(normalUser.getId()); @@ -876,7 +876,7 @@ void searchChatsAppliesPaginationToRoomMatches() throws Exception { // given createDirectChatRoom(normalUser, targetUser); createDirectChatRoom(normalUser, secondTargetUser); - ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(developmentClub)); addRoomMember(groupRoom, normalUser); mockLoginUser(normalUser.getId()); @@ -895,7 +895,7 @@ void searchChatsAppliesPaginationToRoomMatches() throws Exception { void searchChatsWithVeryLargePageReturnsEmptyResult() throws Exception { // given createDirectChatRoom(normalUser, targetUser); - ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(developmentClub)); addRoomMember(groupRoom, normalUser); mockLoginUser(normalUser.getId()); @@ -1013,7 +1013,7 @@ private void createGroupedInviteCandidates(String clubName, String namePrefix, i .clubPosition(ClubPosition.MEMBER) .build()); - ChatRoom groupRoom = persist(ChatRoom.groupOf(club)); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); addRoomMember(groupRoom, normalUser); for (int index = 1; index <= count; index++) { diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java index 6adff35e..b6de3f6e 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -42,7 +42,6 @@ class AnalyzeAndImportPreMembers { @Test @DisplayName("시트 분석 등록 후 사전 회원 가져오기 결과를 반환한다") void analyzeAndImportPreMembersSuccess() throws Exception { - // given given(clubSheetIntegratedService.analyzeAndImportPreMembers( eq(CLUB_ID), eq(REQUESTER_ID), @@ -51,7 +50,6 @@ void analyzeAndImportPreMembersSuccess() throws Exception { SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); - // when & then performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) .andExpect(status().isOk()) .andExpect(jsonPath("$.importedCount").value(2)) @@ -62,7 +60,6 @@ void analyzeAndImportPreMembersSuccess() throws Exception { @Test @DisplayName("구글 스프레드시트 403 오류를 response body로 반환한다") void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { - // given given(clubSheetIntegratedService.analyzeAndImportPreMembers( eq(CLUB_ID), eq(REQUESTER_ID), @@ -71,7 +68,6 @@ void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); - // when & then performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code") @@ -79,5 +75,27 @@ void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { .andExpect(jsonPath("$.message") .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); } + + @Test + @DisplayName("Google Drive invalid_grant 400 오류를 response body detail로 반환한다") + void analyzeAndImportPreMembersInvalidGoogleDriveAuth() throws Exception { + String detail = + "400 Bad Request\nPOST https://oauth2.googleapis.com/token\n{\"error\":\"invalid_grant\"}"; + given(clubSheetIntegratedService.analyzeAndImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willThrow(CustomException.of(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH, detail)); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH.getMessage())) + .andExpect(jsonPath("$.detail").value(detail)); + } } } diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java index 15dd2282..6c3f7afc 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java @@ -93,10 +93,7 @@ void signupSuccess() throws Exception { // 회원이 생성되었는지 확인 clearPersistenceContext(); - User savedUser = userRepository.findAll().stream() - .filter(u -> u.getStudentNumber().equals(studentNumber)) - .findFirst() - .orElse(null); + User savedUser = findSavedUser(studentNumber); assertThat(savedUser).isNotNull(); assertThat(savedUser.getName()).isEqualTo("홍길동"); assertThat(savedUser.getEmail()).isEqualTo(email); @@ -131,10 +128,7 @@ void signupWithPreMemberAutoJoinsClub() throws Exception { // then clearPersistenceContext(); - User savedUser = userRepository.findAll().stream() - .filter(u -> u.getStudentNumber().equals(studentNumber)) - .findFirst() - .orElse(null); + User savedUser = findSavedUser(studentNumber); assertThat(savedUser).isNotNull(); // 동아리 멤버로 등록되었는지 확인 @@ -181,10 +175,7 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { // then clearPersistenceContext(); - User savedUser = userRepository.findAll().stream() - .filter(u -> u.getStudentNumber().equals(studentNumber)) - .findFirst() - .orElse(null); + User savedUser = findSavedUser(studentNumber); assertThat(savedUser).isNotNull(); // 새로운 사용자가 회장으로 등록되었는지 확인 @@ -235,10 +226,7 @@ void signupWithMultiplePreMembersJoinsAllClubs() throws Exception { // then clearPersistenceContext(); - User savedUser = userRepository.findAll().stream() - .filter(u -> u.getStudentNumber().equals(studentNumber)) - .findFirst() - .orElse(null); + User savedUser = findSavedUser(studentNumber); assertThat(savedUser).isNotNull(); // 두 동아리 모두 가입되었는지 확인 @@ -354,10 +342,7 @@ void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { // then clearPersistenceContext(); - User savedUser = userRepository.findAll().stream() - .filter(u -> u.getStudentNumber().equals(studentNumber)) - .findFirst() - .orElse(null); + User savedUser = findSavedUser(studentNumber); assertThat(savedUser).isNotNull(); // 동아리에 가입되지 않았는지 확인 @@ -365,4 +350,13 @@ void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { assertThat(isMember).isFalse(); } } + + private User findSavedUser(String studentNumber) { + return userRepository.findAllByUniversityIdAndStudentNumber( + university.getId(), + studentNumber + ).stream() + .findFirst() + .orElse(null); + } } From e6253cd97bca046f5f9edf97f6d1133e33bc7c66 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:37:48 +0900 Subject: [PATCH 54/55] =?UTF-8?q?feat:=20Google=20Drive=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=97=B0=EA=B2=B0=20URL=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/GoogleDriveOAuthController.java | 13 ++++++ .../GoogleDriveAuthorizationUrlResponse.java | 15 ++++++ .../oauth/GoogleDriveOAuthControllerTest.java | 46 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 src/main/java/gg/agit/konect/infrastructure/oauth/dto/GoogleDriveAuthorizationUrlResponse.java create mode 100644 src/test/java/gg/agit/konect/integration/infrastructure/oauth/GoogleDriveOAuthControllerTest.java diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java index bb5485a4..3912c616 100644 --- a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java @@ -11,15 +11,27 @@ import gg.agit.konect.global.auth.annotation.PublicApi; import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.infrastructure.oauth.dto.GoogleDriveAuthorizationUrlResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/auth/oauth/google/drive") +@Tag(name = "(Normal) OAuth - Google Drive") public class GoogleDriveOAuthController { private final GoogleDriveOAuthService googleDriveOAuthService; + @Operation(summary = "Google Drive 권한 연결 URL 조회") + @GetMapping("/authorize-url") + public ResponseEntity getAuthorizationUrl(@UserId Integer userId) { + String authUrl = googleDriveOAuthService.buildAuthorizationUrl(userId); + return ResponseEntity.ok(new GoogleDriveAuthorizationUrlResponse(authUrl)); + } + + @Operation(summary = "Google Drive 권한 연결 페이지로 리다이렉트") @GetMapping("/authorize") public ResponseEntity authorize(@UserId Integer userId) { String authUrl = googleDriveOAuthService.buildAuthorizationUrl(userId); @@ -27,6 +39,7 @@ public ResponseEntity authorize(@UserId Integer userId) { } @PublicApi + @Operation(summary = "Google Drive OAuth callback 처리") @GetMapping("/callback") public ResponseEntity callback( @RequestParam("code") String code, diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/dto/GoogleDriveAuthorizationUrlResponse.java b/src/main/java/gg/agit/konect/infrastructure/oauth/dto/GoogleDriveAuthorizationUrlResponse.java new file mode 100644 index 00000000..cea6906f --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/dto/GoogleDriveAuthorizationUrlResponse.java @@ -0,0 +1,15 @@ +package gg.agit.konect.infrastructure.oauth.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GoogleDriveAuthorizationUrlResponse( + @Schema( + description = "Google Drive 권한 연결을 위해 브라우저를 이동시킬 authorize URL", + example = "https://accounts.google.com/o/oauth2/v2/auth?client_id=example&redirect_uri=https://api.stage.agit.gg/auth/oauth/google/drive/callback&response_type=code&scope=https://www.googleapis.com/auth/drive&access_type=offline&prompt=consent&state=example-state", + requiredMode = REQUIRED + ) + String authorizationUrl +) { +} diff --git a/src/test/java/gg/agit/konect/integration/infrastructure/oauth/GoogleDriveOAuthControllerTest.java b/src/test/java/gg/agit/konect/integration/infrastructure/oauth/GoogleDriveOAuthControllerTest.java new file mode 100644 index 00000000..503fc8db --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/infrastructure/oauth/GoogleDriveOAuthControllerTest.java @@ -0,0 +1,46 @@ +package gg.agit.konect.integration.infrastructure.oauth; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.infrastructure.oauth.GoogleDriveOAuthService; +import gg.agit.konect.support.IntegrationTestSupport; + +class GoogleDriveOAuthControllerTest extends IntegrationTestSupport { + + @MockitoBean + private GoogleDriveOAuthService googleDriveOAuthService; + + private static final Integer USER_ID = 100; + private static final String AUTHORIZATION_URL = + "https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&state=test-state"; + + @BeforeEach + void setUp() throws Exception { + mockLoginUser(USER_ID); + } + + @Nested + @DisplayName("GET /auth/oauth/google/drive/authorize-url - Google Drive 권한 연결 URL 조회") + class GetAuthorizationUrl { + + @Test + @DisplayName("로그인 사용자의 authorize URL을 JSON으로 반환한다") + void getAuthorizationUrl() throws Exception { + given(googleDriveOAuthService.buildAuthorizationUrl(eq(USER_ID))) + .willReturn(AUTHORIZATION_URL); + + performGet("/auth/oauth/google/drive/authorize-url") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authorizationUrl").value(AUTHORIZATION_URL)); + } + } +} From 6782851369e88075a4b3fc87aca72b169ff9107e Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:16:03 +0900 Subject: [PATCH 55/55] =?UTF-8?q?fix:=20=EC=8B=9C=ED=8A=B8=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=8B=9C=20=EC=82=AC=EC=A0=84=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 시트 동기화에 사전 회원 포함 * chore: 시트 동기화 로그 포맷 정리 * fix: 시트 동기화 수 응답 오버플로우 감지 추가 * test: 시트 동기화 테스트 필드 탐색 안정화 * docs: Sheet 관련 Swagger 설명 정정 --- .../club/controller/ClubMemberSheetApi.java | 2 +- .../club/dto/ClubMemberSheetSyncResponse.java | 2 +- .../repository/ClubPreMemberRepository.java | 2 + .../club/service/ClubMemberSheetService.java | 5 +- .../club/service/SheetSyncExecutor.java | 114 +++++++++++++----- .../service/ClubMemberSheetServiceTest.java | 79 ++++++++++++ .../club/service/SheetSyncExecutorTest.java | 92 ++++++++++++++ 7 files changed, 266 insertions(+), 30 deletions(-) create mode 100644 src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index 29ebaf24..47821587 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -36,7 +36,7 @@ ResponseEntity updateSheetId( @Operation( summary = "동아리 인명부 스프레드시트 동기화", - description = "등록된 구글 스프레드시트에 동아리 회원 인명부를 동기화합니다. " + description = "등록된 구글 스프레드시트에 동아리 회원과 사전 회원 인명부를 동기화합니다. " + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT)을 지정할 수 있으며, " + "ascending으로 오름차순/내림차순을 설정합니다. " + "가입 승인·탈퇴 시에도 자동으로 동기화됩니다." diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java index 886892b6..9daec1d0 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java @@ -3,7 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; public record ClubMemberSheetSyncResponse( - @Schema(description = "동기화된 회원 수", example = "42") + @Schema(description = "동기화 요청된 회원 및 사전 회원 수", example = "42") int syncedMemberCount, @Schema( diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java index eec13a77..55d7a6d9 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java @@ -95,4 +95,6 @@ void deleteByClubIdAndStudentNumberIn( ClubPreMember save(ClubPreMember preMember); List saveAll(Iterable preMembers); + + long countByClubId(Integer clubId); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index b5ccb741..f92c9b22 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -13,6 +13,7 @@ import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class ClubMemberSheetService { private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; + private final ClubPreMemberRepository clubPreMemberRepository; private final ClubPermissionValidator clubPermissionValidator; private final SheetSyncExecutor sheetSyncExecutor; private final SheetHeaderMapper sheetHeaderMapper; @@ -92,8 +94,9 @@ public ClubMemberSheetSyncResponse syncMembersToSheet( } long memberCount = clubMemberRepository.countByClubId(clubId); + long preMemberCount = clubPreMemberRepository.countByClubId(clubId); sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); - return ClubMemberSheetSyncResponse.of((int)memberCount, spreadsheetId); + return ClubMemberSheetSyncResponse.of(Math.toIntExact(memberCount + preMemberCount), spreadsheetId); } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index e36759cd..31309e4e 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -33,8 +33,10 @@ import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.util.PhoneNumberNormalizer; import lombok.RequiredArgsConstructor; @@ -57,6 +59,7 @@ public class SheetSyncExecutor { private final Sheets googleSheetsService; private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; + private final ClubPreMemberRepository clubPreMemberRepository; private final ObjectMapper objectMapper; private final ApplicationEventPublisher applicationEventPublisher; @@ -71,7 +74,8 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as SheetColumnMapping mapping = resolveMapping(club); List members = clubMemberRepository.findAllByClubId(clubId); - List sorted = sort(members, sortKey, ascending); + List preMembers = clubPreMemberRepository.findAllByClubId(clubId); + List sorted = sort(toSheetSyncRows(members, preMembers), sortKey, ascending); try { if (club.getSheetColumnMapping() != null) { @@ -80,7 +84,12 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as clearAndWriteAll(spreadsheetId, sorted); applyFormat(spreadsheetId); } - log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); + log.info( + "Sheet sync done. clubId={}, members={}, preMembers={}", + clubId, + members.size(), + preMembers.size() + ); } catch (IOException e) { if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { log.warn( @@ -134,7 +143,7 @@ private SheetColumnMapping resolveMapping(Club club) { private void updateMappedColumns( String spreadsheetId, - List members, + List members, SheetColumnMapping mapping ) throws IOException { int dataStartRow = mapping.getDataStartRow(); @@ -185,24 +194,24 @@ private void clearMappedColumns( } private Map> buildColumnData( - List members, + List members, SheetColumnMapping mapping ) { Map> columns = new HashMap<>(); - for (ClubMember member : members) { + for (SheetSyncRow member : members) { putValue(columns, mapping, SheetColumnMapping.NAME, - member.getUser().getName()); + member.name()); putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, - member.getUser().getStudentNumber()); + member.studentNumber()); putValue(columns, mapping, SheetColumnMapping.EMAIL, - member.getUser().getEmail()); + member.email()); putValue(columns, mapping, SheetColumnMapping.PHONE, - PhoneNumberNormalizer.format(member.getUser().getPhoneNumber())); + member.phone()); putValue(columns, mapping, SheetColumnMapping.POSITION, - member.getClubPosition().getDescription()); + member.positionDescription()); putValue(columns, mapping, SheetColumnMapping.JOINED_AT, - member.getCreatedAt().format(DATE_FORMATTER)); + member.joinedAt()); } return columns; @@ -222,7 +231,7 @@ private void putValue( private void clearAndWriteAll( String spreadsheetId, - List members + List members ) throws IOException { String clearRange = "A:F"; googleSheetsService.spreadsheets().values() @@ -232,15 +241,14 @@ private void clearAndWriteAll( List> rows = new ArrayList<>(); rows.add(HEADER_ROW); - for (ClubMember member : members) { - String phone = PhoneNumberNormalizer.format(member.getUser().getPhoneNumber()); + for (SheetSyncRow member : members) { rows.add(List.of( - member.getUser().getName(), - member.getUser().getStudentNumber(), - member.getUser().getEmail(), - phone != null ? phone : "", - member.getClubPosition().getDescription(), - member.getCreatedAt().format(DATE_FORMATTER) + member.name(), + member.studentNumber(), + member.email(), + member.phone(), + member.positionDescription(), + member.joinedAt() )); } @@ -272,16 +280,16 @@ private void applyFormat(String spreadsheetId) throws IOException { .execute(); } - private List sort( - List members, + private List sort( + List members, ClubSheetSortKey sortKey, boolean ascending ) { - Comparator comparator = switch (sortKey) { - case NAME -> Comparator.comparing(m -> m.getUser().getName()); - case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); - case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); - case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); + Comparator comparator = switch (sortKey) { + case NAME -> Comparator.comparing(SheetSyncRow::name); + case STUDENT_ID -> Comparator.comparing(SheetSyncRow::studentNumber); + case POSITION -> Comparator.comparingInt(SheetSyncRow::positionPriority); + case JOINED_AT -> Comparator.comparing(SheetSyncRow::joinedAtRaw); }; @@ -292,6 +300,20 @@ private List sort( return members.stream().sorted(comparator).toList(); } + private List toSheetSyncRows( + List members, + List preMembers + ) { + List rows = new ArrayList<>(members.size() + preMembers.size()); + for (ClubMember member : members) { + rows.add(SheetSyncRow.from(member)); + } + for (ClubPreMember preMember : preMembers) { + rows.add(SheetSyncRow.from(preMember)); + } + return rows; + } + private String columnLetter(int index) { StringBuilder sb = new StringBuilder(); index++; @@ -302,4 +324,42 @@ private String columnLetter(int index) { } return sb.toString(); } + + private record SheetSyncRow( + String name, + String studentNumber, + String email, + String phone, + String positionDescription, + int positionPriority, + String joinedAt, + java.time.LocalDateTime joinedAtRaw + ) { + private static SheetSyncRow from(ClubMember member) { + String phone = PhoneNumberNormalizer.format(member.getUser().getPhoneNumber()); + return new SheetSyncRow( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + phone != null ? phone : "", + member.getClubPosition().getDescription(), + member.getClubPosition().getPriority(), + member.getCreatedAt().format(DATE_FORMATTER), + member.getCreatedAt() + ); + } + + private static SheetSyncRow from(ClubPreMember preMember) { + return new SheetSyncRow( + preMember.getName(), + preMember.getStudentNumber(), + "", + "", + preMember.getClubPosition().getDescription(), + preMember.getClubPosition().getPriority(), + preMember.getCreatedAt().format(DATE_FORMATTER), + preMember.getCreatedAt() + ); + } + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java new file mode 100644 index 00000000..7db589d7 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java @@ -0,0 +1,79 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class ClubMemberSheetServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetSyncExecutor sheetSyncExecutor; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ClubMemberSheetService clubMemberSheetService; + + @Test + @DisplayName("시트 동기화 수에 사전 회원도 포함한다") + void syncMembersToSheetIncludesPreMembersInCount() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.countByClubId(clubId)).willReturn(2L); + given(clubPreMemberRepository.countByClubId(clubId)).willReturn(3L); + + // when + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + ); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + assertThat(response.syncedMemberCount()).isEqualTo(5); + assertThat(response.sheetUrl()) + .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java index 92aea1bf..7aa5115f 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java @@ -7,6 +7,8 @@ import static org.mockito.Mockito.verify; import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import java.lang.reflect.Field; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -17,16 +19,26 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetResponse; import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.UpdateValuesResponse; +import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; class SheetSyncExecutorTest extends ServiceTestSupport { @@ -42,12 +54,21 @@ class SheetSyncExecutorTest extends ServiceTestSupport { @Mock private Sheets.Spreadsheets.Values.Clear clearRequest; + @Mock + private Sheets.Spreadsheets.Values.Update updateRequest; + + @Mock + private Sheets.Spreadsheets.BatchUpdate batchUpdateRequest; + @Mock private ClubRepository clubRepository; @Mock private ClubMemberRepository clubMemberRepository; + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + @Mock private ObjectMapper objectMapper; @@ -68,6 +89,7 @@ void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { given(clubRepository.getById(clubId)).willReturn(club); given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); + given(clubPreMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); given(spreadsheets.values()).willReturn(values); given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) @@ -85,4 +107,74 @@ void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { && sheetSyncFailedEvent.accessDenied() )); } + + @Test + @DisplayName("시트 동기화 시 사전 회원도 함께 덮어쓴다") + void executeWithSortWritesClubMembersAndPreMembers() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + User memberUser = UserFixture.createUser(club.getUniversity(), "김회원", "2021000001"); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + setCreatedAt(member, LocalDateTime.of(2024, 3, 1, 10, 0)); + + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber("2024000001") + .name("박사전") + .clubPosition(ClubPosition.MEMBER) + .build(); + setCreatedAt(preMember, LocalDateTime.of(2024, 3, 2, 10, 0)); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(member)); + given(clubPreMemberRepository.findAllByClubId(clubId)).willReturn(List.of(preMember)); + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) + .willReturn(clearRequest); + given(values.update(eq(spreadsheetId), eq("A1"), any(ValueRange.class))) + .willReturn(updateRequest); + given(updateRequest.setValueInputOption("USER_ENTERED")).willReturn(updateRequest); + given(updateRequest.execute()).willReturn(new UpdateValuesResponse()); + given(spreadsheets.batchUpdate(eq(spreadsheetId), any())).willReturn(batchUpdateRequest); + given(batchUpdateRequest.execute()).willReturn(new BatchUpdateSpreadsheetResponse()); + + // when + sheetSyncExecutor.executeWithSort(clubId, ClubSheetSortKey.NAME, true); + + // then + verify(values).update(eq(spreadsheetId), eq("A1"), argThat((ValueRange body) -> + body.getValues().equals(List.of( + List.of("Name", "StudentId", "Email", "Phone", "Position", "JoinedAt"), + List.of("김회원", "2021000001", "2021000001@koreatech.ac.kr", "", "일반회원", "2024-03-01"), + List.of("박사전", "2024000001", "", "", "일반회원", "2024-03-02") + )) + )); + } + + private void setCreatedAt(Object target, LocalDateTime createdAt) throws Exception { + Field createdAtField = findField(target.getClass(), "createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(target, createdAt); + } + + private Field findField(Class type, String fieldName) throws NoSuchFieldException { + Class current = type; + + while (current != null) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + + throw new NoSuchFieldException( + "Field '%s' not found in class hierarchy of %s".formatted(fieldName, type.getName()) + ); + } }