From 8de1c80c579469f8056af49425eec8d15c954fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EA=B7=9C=ED=98=81?= <126947828+Gyuhyeok99@users.noreply.github.com> Date: Wed, 5 Nov 2025 21:54:09 +0900 Subject: [PATCH 01/31] =?UTF-8?q?fix:=20yml=20=EB=93=A4=EC=97=AC=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=88=98=EC=A0=95=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: yml 들여쓰기 수정 * fix: jdk 변경 --- Dockerfile | 2 +- src/main/resources/secret | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 773d1ba16..f598c9395 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # JDK 버전 설정 -FROM openjdk:17-jdk +FROM eclipse-temurin:17-jdk # JAR_FILE 변수 정의 ARG JAR_FILE=./build/libs/solid-connection-0.0.1-SNAPSHOT.jar diff --git a/src/main/resources/secret b/src/main/resources/secret index ae3e90ef7..8300cdeca 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit ae3e90ef74f56e93be1ede280bbc5f330ca8e297 +Subproject commit 8300cdecaebfc28fd657064a00a44815a7bb2eee From 6b9fb2b98080d891babb7779e16f693aeb09515d Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:55:18 +0900 Subject: [PATCH 02/31] =?UTF-8?q?refactor:=20=EB=A9=98=ED=86=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B1=84=ED=8C=85=20=EA=B4=80=EB=A0=A8=20API=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=88=98=EC=A0=95=20(#537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 멘토의 멘토링 조회 응답에서 mentoringId가 아니라 roomId를 포함하도록 * refactor: 파트너가 멘토인 경우 partnerId는 mentorId로 - AS IS: 멘토/멘티 모두 partnerId가 siteUserId - TO BE: 멘티: siteUserId, 멘토: mentorId * refactor: 응답의 senderId가 mentorId/siteUserId가 되도록 * refactor: senderId에 해당하는 chatParticipant가 없을 경우 예외 처리하는 로직 추가 * refactor: 메서드명에 맞게 시그니처 변경 * refactor: getChatMessages 메서드에서 응답으로 siteUserId를 넘겨주도록 - AS IS: mentorId(mentor) / siteUserId(mentee) - TO BE: siteUserId(all) * refactor: 헬퍼 메서드로 메서드 복잡성을 분산한다 * refactor: getChatPartner 메서드의 응답으로 siteUserId를 넘겨주도록 - AS IS: mentorId(mentor) / siteUserId(mentee) - TO BE: siteUserId(all) --- .../chat/domain/ChatMessage.java | 2 +- .../chat/dto/ChatMessageResponse.java | 2 +- .../chat/dto/ChatParticipantResponse.java | 2 +- .../chat/service/ChatService.java | 46 ++++++++++++++++--- .../dto/MentoringForMentorResponse.java | 6 +-- .../mentor/repository/MentorRepository.java | 4 ++ .../mentor/service/MentoringQueryService.java | 30 +++++++----- .../service/MentoringQueryServiceTest.java | 17 ++++--- 8 files changed, 78 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java index 170a93f05..aa7369451 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -28,7 +28,7 @@ public class ChatMessage extends BaseEntity { @Column(nullable = false, length = 500) private String content; - private long senderId; + private long senderId; // chat_participant의 id @ManyToOne(fetch = FetchType.LAZY) private ChatRoom chatRoom; diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java index a3728b7fd..b9551b9b2 100644 --- a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java @@ -6,7 +6,7 @@ public record ChatMessageResponse( long id, String content, - long senderId, + long senderId, // siteUserId ZonedDateTime createdAt, List attachments ) { diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java index ffa6b9b8c..18276b561 100644 --- a/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java @@ -1,7 +1,7 @@ package com.example.solidconnection.chat.dto; public record ChatParticipantResponse( - long partnerId, + long partnerId, // siteUserId String nickname, String profileUrl ) { diff --git a/src/main/java/com/example/solidconnection/chat/service/ChatService.java b/src/main/java/com/example/solidconnection/chat/service/ChatService.java index 78530d752..57f8cad65 100644 --- a/src/main/java/com/example/solidconnection/chat/service/ChatService.java +++ b/src/main/java/com/example/solidconnection/chat/service/ChatService.java @@ -15,6 +15,7 @@ import com.example.solidconnection.chat.dto.ChatMessageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageSendResponse; import com.example.solidconnection.chat.dto.ChatParticipantResponse; +import com.example.solidconnection.chat.dto.ChatRoomData; import com.example.solidconnection.chat.dto.ChatRoomListResponse; import com.example.solidconnection.chat.dto.ChatRoomResponse; import com.example.solidconnection.chat.repository.ChatMessageRepository; @@ -23,11 +24,15 @@ import com.example.solidconnection.chat.repository.ChatRoomRepository; import com.example.solidconnection.common.dto.SliceResponse; import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.chat.dto.ChatRoomData; +import com.example.solidconnection.mentor.repository.MentorRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.context.annotation.Lazy; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -43,6 +48,7 @@ public class ChatService { private final ChatParticipantRepository chatParticipantRepository; private final ChatReadStatusRepository chatReadStatusRepository; private final SiteUserRepository siteUserRepository; + private final MentorRepository mentorRepository; private final SimpMessageSendingOperations simpMessageSendingOperations; @@ -51,12 +57,14 @@ public ChatService(ChatRoomRepository chatRoomRepository, ChatParticipantRepository chatParticipantRepository, ChatReadStatusRepository chatReadStatusRepository, SiteUserRepository siteUserRepository, + MentorRepository mentorRepository, @Lazy SimpMessageSendingOperations simpMessageSendingOperations) { this.chatRoomRepository = chatRoomRepository; this.chatMessageRepository = chatMessageRepository; this.chatParticipantRepository = chatParticipantRepository; this.chatReadStatusRepository = chatReadStatusRepository; this.siteUserRepository = siteUserRepository; + this.mentorRepository = mentorRepository; this.simpMessageSendingOperations = simpMessageSendingOperations; } @@ -114,13 +122,38 @@ public SliceResponse getChatMessages(long siteUserId, long Slice chatMessages = chatMessageRepository.findByRoomIdWithPaging(roomId, pageable); - List content = chatMessages.getContent().stream() - .map(this::toChatMessageResponse) - .toList(); + Map participantIdToParticipant = buildParticipantIdToParticipantMap(chatMessages); + List content = buildChatMessageResponses(chatMessages, participantIdToParticipant); return SliceResponse.of(content, chatMessages); } + // senderId(chatParticipantId)로 chatParticipant 맵 생성 + private Map buildParticipantIdToParticipantMap(Slice chatMessages) { + Set participantIds = chatMessages.getContent().stream() + .map(ChatMessage::getSenderId) + .collect(Collectors.toSet()); + + return chatParticipantRepository.findAllById(participantIds).stream() + .collect(Collectors.toMap(ChatParticipant::getId, Function.identity())); + } + + private List buildChatMessageResponses( + Slice chatMessages, + Map participantIdToParticipant + ) { + return chatMessages.getContent().stream() + .map(message -> { + ChatParticipant senderParticipant = participantIdToParticipant.get(message.getSenderId()); + if (senderParticipant == null) { + throw new CustomException(CHAT_PARTICIPANT_NOT_FOUND); + } + long externalSenderId = senderParticipant.getSiteUserId(); + return toChatMessageResponse(message, externalSenderId); + }) + .toList(); + } + @Transactional(readOnly = true) public ChatParticipantResponse getChatPartner(long siteUserId, Long roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) @@ -128,6 +161,7 @@ public ChatParticipantResponse getChatPartner(long siteUserId, Long roomId) { ChatParticipant partnerParticipant = findPartner(chatRoom, siteUserId); SiteUser siteUser = siteUserRepository.findById(partnerParticipant.getSiteUserId()) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + return ChatParticipantResponse.of(siteUser.getId(), siteUser.getNickname(), siteUser.getProfileImageUrl()); } @@ -148,7 +182,7 @@ public void validateChatRoomParticipant(long siteUserId, long roomId) { } } - private ChatMessageResponse toChatMessageResponse(ChatMessage message) { + private ChatMessageResponse toChatMessageResponse(ChatMessage message, long externalSenderId) { List attachments = message.getChatAttachments().stream() .map(attachment -> ChatAttachmentResponse.of( attachment.getId(), @@ -162,7 +196,7 @@ private ChatMessageResponse toChatMessageResponse(ChatMessage message) { return ChatMessageResponse.of( message.getId(), message.getContent(), - message.getSenderId(), + externalSenderId, message.getCreatedAt(), attachments ); diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMentorResponse.java b/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMentorResponse.java index 8a41fba84..46791d071 100644 --- a/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMentorResponse.java +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentoringForMentorResponse.java @@ -6,7 +6,7 @@ import java.time.ZonedDateTime; public record MentoringForMentorResponse( - long mentoringId, + Long roomId, String profileImageUrl, String nickname, boolean isChecked, @@ -14,9 +14,9 @@ public record MentoringForMentorResponse( ZonedDateTime createdAt ) { - public static MentoringForMentorResponse of(Mentoring mentoring, SiteUser partner) { + public static MentoringForMentorResponse of(Mentoring mentoring, SiteUser partner, Long roomId) { return new MentoringForMentorResponse( - mentoring.getId(), + roomId, partner.getProfileImageUrl(), partner.getNickname(), mentoring.getCheckedAtByMentor() != null, diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java index 430602f1e..85dbbc0bf 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java @@ -2,7 +2,9 @@ import com.example.solidconnection.location.region.domain.Region; import com.example.solidconnection.mentor.domain.Mentor; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -23,4 +25,6 @@ public interface MentorRepository extends JpaRepository { WHERE u.region = :region """) Slice findAllByRegion(@Param("region") Region region, Pageable pageable); + + List findAllBySiteUserIdIn(Set siteUserIds); } diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java b/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java index ad57d83ad..e307d9e57 100644 --- a/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java +++ b/src/main/java/com/example/solidconnection/mentor/service/MentoringQueryService.java @@ -121,17 +121,6 @@ public SliceResponse getMentoringsForMentee( return SliceResponse.of(content, mentoringSlice); } - // N+1 을 해결하면서 멘토링의 채팅방 정보 조회 - private Map mapMentoringIdToChatRoomIdWithBatchQuery(List mentorings) { - List mentoringIds = mentorings.stream() - .map(Mentoring::getId) - .distinct() - .toList(); - List chatRooms = chatRoomRepository.findAllByMentoringIdIn(mentoringIds); - return chatRooms.stream() - .collect(Collectors.toMap(ChatRoom::getMentoringId, ChatRoom::getId)); - } - @Transactional(readOnly = true) public SliceResponse getMentoringsForMentor(long siteUserId, Pageable pageable) { Mentor mentor = mentorRepository.findBySiteUserId(siteUserId) @@ -143,9 +132,15 @@ public SliceResponse getMentoringsForMentor(long sit Mentoring::getMenteeId ); + Map mentoringIdToChatRoomId = mapMentoringIdToChatRoomIdWithBatchQuery(mentoringSlice.getContent()); + List content = new ArrayList<>(); for (Mentoring mentoring : mentoringSlice) { - content.add(MentoringForMentorResponse.of(mentoring, mentoringToPartnerUser.get(mentoring))); + content.add(MentoringForMentorResponse.of( + mentoring, + mentoringToPartnerUser.get(mentoring), + mentoringIdToChatRoomId.get(mentoring.getId()) + )); } return SliceResponse.of(content, mentoringSlice); @@ -168,4 +163,15 @@ private Map mapMentoringToPartnerUserWithBatchQuery( mentoring -> partnerIdToPartnerUsermap.get(getPartnerId.apply(mentoring)) )); } + + // N+1 을 해결하면서 멘토링의 채팅방 정보 조회 + private Map mapMentoringIdToChatRoomIdWithBatchQuery(List mentorings) { + List mentoringIds = mentorings.stream() + .map(Mentoring::getId) + .distinct() + .toList(); + List chatRooms = chatRoomRepository.findAllByMentoringIdIn(mentoringIds); + return chatRooms.stream() + .collect(Collectors.toMap(ChatRoom::getMentoringId, ChatRoom::getId)); + } } diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java index cb680b986..959d8e491 100644 --- a/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/mentor/service/MentoringQueryServiceTest.java @@ -97,15 +97,18 @@ class 멘토의_멘토링_목록_조회_테스트 { Mentoring mentoring2 = mentoringFixture.승인된_멘토링(mentor1.getId(), menteeUser2.getId()); Mentoring mentoring3 = mentoringFixture.거절된_멘토링(mentor1.getId(), menteeUser3.getId()); + ChatRoom chatRoom2 = chatRoomFixture.멘토링_채팅방(mentoring2.getId()); + // when SliceResponse response = mentoringQueryService.getMentoringsForMentor(mentorUser1.getId(), pageable); // then - assertThat(response.content()).extracting(MentoringForMentorResponse::mentoringId) + assertThat(response.content()) + .extracting(MentoringForMentorResponse::verifyStatus, MentoringForMentorResponse::roomId) .containsExactlyInAnyOrder( - mentoring1.getId(), - mentoring2.getId(), - mentoring3.getId() + tuple(VerifyStatus.PENDING, null), + tuple(VerifyStatus.APPROVED, chatRoom2.getId()), + tuple(VerifyStatus.REJECTED, null) ); } @@ -137,10 +140,10 @@ class 멘토의_멘토링_목록_조회_테스트 { // then assertThat(response.content()) - .extracting(MentoringForMentorResponse::mentoringId, MentoringForMentorResponse::isChecked) + .extracting(MentoringForMentorResponse::nickname, MentoringForMentorResponse::isChecked) .containsExactlyInAnyOrder( - tuple(mentoring1.getId(), false), - tuple(mentoring2.getId(), true) + tuple(menteeUser1.getNickname(), false), + tuple(menteeUser2.getNickname(), true) ); } From 4dbc07e782514f792d02bd35d6c12efc7251fc4f Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:13:33 +0900 Subject: [PATCH 03/31] =?UTF-8?q?refactor:=20CD=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#552)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: deprecated된 base image를 eclipse-temurin:17-jdk로 변경 * refactor: scp 파일 전송하는 방식에서 GHCR로 push/pull하도록 변경 * fix: GHCR image 제거시 Org의 GITHUB_TOKEN 사용하도록 변경 * refactor : scp 파일 전송하는 방식에서 GHCR로 push/pull하도록 prod-cd.yml과 docker-compose.prod.yml 변경 * fix: prod 인스턴스 old image 이름 통일 * fix: prod-cd.yml StrictHostKeyChecking 옵션 문법 오류 수정 * fix: prod-cd.yml StrictHostKeyChecking 옵션 문법 오류 수정 * fix: dev-cd.yml Old images 정리 작업 중 이미지 이름 불일치 문제 해결 * chore: 마지막 줄 개행 추가 * chore: 마지막 줄 개행 추가 * feat: stage 인스턴스에 대한 최신 이미지 5개 유지 기능 및 old 이미지 제거 기능 추가 * chore: 중복된 환경변수 지정 제거 * chore: 중복된 pem키 생성 로직 제거 * fix: 잘못된 pem키 이름 수정 * refactor: 원격 호스트에서 pull할 경우, 최소 권한으로 실행하도록 Github App으로 임시토큰 발급하도록 수정 --- .github/workflows/dev-cd.yml | 165 ++++++++++++++++++--------- .github/workflows/prod-cd.yml | 209 +++++++++++++++++++++------------- docker-compose.dev.yml | 4 +- docker-compose.prod.yml | 4 +- src/main/resources/secret | 2 +- 5 files changed, 244 insertions(+), 140 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index f0d6d3cb0..9a3ae34a4 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -10,6 +10,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: write steps: - name: Checkout the code @@ -18,81 +19,135 @@ jobs: token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} submodules: true + # --- Java, Gradle 설정 --- - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - - # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. - # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - name: Setup Gradle - uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 - + uses: gradle/actions/setup-gradle@v3 - name: Grant execute permission for Gradle wrapper(gradlew) run: chmod +x ./gradlew - - name: Build with Gradle run: ./gradlew bootJar - - name: Copy jar file to remote - uses: appleboy/scp-action@master + # --- Docker 설정 --- + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 with: - host: ${{ secrets.DEV_HOST }} - username: ${{ secrets.DEV_USERNAME }} - key: ${{ secrets.DEV_PRIVATE_KEY }} - source: "./build/libs/*.jar" - target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" - - - name: Copy docker file to remote - uses: appleboy/scp-action@master + platforms: linux/arm64 + - name: Log in to GitHub Container Registry (GHCR) + uses: docker/login-action@v3 with: - host: ${{ secrets.DEV_HOST }} - username: ${{ secrets.DEV_USERNAME }} - key: ${{ secrets.DEV_PRIVATE_KEY }} - source: "./Dockerfile" - target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Copy docker compose file to remote - uses: appleboy/scp-action@master - with: - host: ${{ secrets.DEV_HOST }} - username: ${{ secrets.DEV_USERNAME }} - key: ${{ secrets.DEV_PRIVATE_KEY }} - source: "./docker-compose.dev.yml" - target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" + # --- 2. 이미지 메타데이터(이름, 태그) 정의 --- + # 빌드/푸시 단계와 SSH 단계에서 공통으로 사용할 변수를 미리 정의합니다. + - name: Define image name and tag + id: image_meta + run: | + OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + IMAGE_TAG=$(date +'%Y%m%d-%H%M%S') + echo "image_name=ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev" >> $GITHUB_OUTPUT + echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT - - name: Copy alloy config file to remote - uses: appleboy/scp-action@master + # --- 3. Docker 이미지 빌드, 푸시, 캐시 --- + # 'docker/build-push-action'을 사용하여 캐시 옵션을 적용합니다. + - name: Build, push, and cache Docker image + uses: docker/build-push-action@v5 with: - host: ${{ secrets.DEV_HOST }} - username: ${{ secrets.DEV_USERNAME }} - key: ${{ secrets.DEV_PRIVATE_KEY }} - source: "./docs/infra-config/config.alloy" - target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/" + context: . + platforms: linux/arm64 + push: true + tags: ${{ format('{0}:{1}', steps.image_meta.outputs.image_name, steps.image_meta.outputs.image_tag) }} + cache-from: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache + cache-to: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache,mode=max - - name: Copy nginx config to remote - uses: appleboy/scp-action@master + # --- 4. Github App으로 임시 토큰 생성 --- + - name: Create installation token + id: app + uses: actions/create-github-app-token@v2 with: - host: ${{ secrets.DEV_HOST }} - username: ${{ secrets.DEV_USERNAME }} - key: ${{ secrets.DEV_PRIVATE_KEY }} - source: "./docs/infra-config/nginx.dev.conf" - target: "/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/nginx" - rename: "default.conf" + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + # --- 5. 설정 파일들만 scp로 전송 --- + - name: Copy config files to remote + run: | + echo "${{ secrets.DEV_PRIVATE_KEY }}" > deploy_key.pem + chmod 600 deploy_key.pem + + scp -i deploy_key.pem \ + -o StrictHostKeyChecking=no \ + ./docker-compose.dev.yml \ + ./docs/infra-config/config.alloy \ + ./docs/infra-config/nginx.dev.conf \ + ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }}:/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/ + # --- 6. 서버에서 'docker pull' 및 서비스 재시작 --- - name: Run docker compose and apply nginx config - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEV_HOST }} - username: ${{ secrets.DEV_USERNAME }} - key: ${{ secrets.DEV_PRIVATE_KEY }} - script_stop: true - script: | - sudo cp /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/nginx/default.conf /etc/nginx/conf.d/default.conf + run: | + ssh -i deploy_key.pem \ + -o StrictHostKeyChecking=no \ + ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }} \ + ' + set -e + + # 1. 변수를 'image_meta' 단계의 출력값에서 가져옴 + export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + export IMAGE_TAG_ONLY=${{ steps.image_meta.outputs.image_tag }} + export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG_ONLY}" + + # 2. 서버가 GHCR에 로그인 (pull 받기 위해) + echo "${{ steps.app.outputs.token }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin + + # 3. docker pull (전체 이미지 이름 사용) + echo "Pulling new image layer from GHCR..." + docker pull $FULL_IMAGE_NAME + + # 4. 작업 디렉토리로 이동 및 Nginx 설정 이동 + cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev + mkdir -p ./nginx + mv ./nginx.dev.conf ./nginx/default.conf + + # 5. Nginx 재시작 + sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf sudo nginx -t sudo nginx -s reload - - cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev + + # 6. Docker Compose 재시작 + echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d --build + IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.dev.yml up -d + + # 7. 이미지 정리 + echo "Pruning dangling docker images..." + docker image prune -f + + # 8. stage 인스턴스의 오래된 태그 이미지 정리 (최신 5개 유지) + echo "Cleaning up old tagged images on host, keeping last 5..." + IMAGE_NAME_BASE="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev" + + docker images "${IMAGE_NAME_BASE}" --format "{{.Tag}}" | \ + sort -r | \ + tail -n +6 | \ + xargs -I {} docker rmi "${IMAGE_NAME_BASE}:{}" || true + + echo "Deploy and Docker Compose restart finished." + ' + + # --- 6. 이미지 정리 --- + - name: Clean up old image versions from GHCR + if: success() + uses: snok/container-retention-policy@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + image-names: solid-connection-dev + delete-untagged: true + keep-n-tags: 5 + account-type: org + org-name: ${{ github.repository_owner }} + cut-off: '7 days ago UTC' diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index d52c524c9..c96074d19 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -10,89 +10,142 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: write steps: - - name: Checkout the code - uses: actions/checkout@v4 - with: - token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} - submodules: true + - name: Checkout the code + uses: actions/checkout@v4 + with: + token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} + submodules: true - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' + # --- Java, Gradle 설정 (dev와 동일하게 버전 통일) --- + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Grant execute permission for Gradle wrapper(gradlew) + run: chmod +x ./gradlew + - name: Build with Gradle + run: ./gradlew bootJar - # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. - # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - - name: Setup Gradle - uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + # --- 1. Docker 설정 --- + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/arm64 + - name: Log in to GitHub Container Registry (GHCR) + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Grant execute permission for Gradle wrapper(gradlew) - run: chmod +x ./gradlew + # --- 2. 이미지 메타데이터(이름, 태그) 정의 --- + - name: Define image name and tag + id: image_meta + run: | + OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + IMAGE_TAG=$(date +'%Y%m%d-%H%M%S') + echo "image_name=ghcr.io/${OWNER_LOWERCASE}/solid-connection-server" >> $GITHUB_OUTPUT + echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT - - name: Build with Gradle - run: ./gradlew bootJar - - - name: Copy jar file to remote - uses: appleboy/scp-action@master - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.PRIVATE_KEY }} - source: "./build/libs/*.jar" - target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" - - - name: Copy docker file to remote - uses: appleboy/scp-action@master - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.PRIVATE_KEY }} - source: "./Dockerfile" - target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" - - - name: Copy docker compose file to remote - uses: appleboy/scp-action@master - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.PRIVATE_KEY }} - source: "./docker-compose.prod.yml" - target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" - - - name: Copy alloy config file to remote - uses: appleboy/scp-action@master - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.PRIVATE_KEY }} - source: "./docs/infra-config/config.alloy" - target: "/home/${{ secrets.USERNAME }}/solid-connect-server/" - - - name: Copy nginx config to remote - uses: appleboy/scp-action@master - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.PRIVATE_KEY }} - source: "./docs/infra-config/nginx.prod.conf" - target: "/home/${{ secrets.USERNAME }}/solid-connection-prod/nginx" - rename: "default.conf" + # --- 3. Docker 이미지 빌드, 푸시, 캐시 --- + - name: Build, push, and cache Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ format('{0}:{1}', steps.image_meta.outputs.image_name, steps.image_meta.outputs.image_tag) }} + cache-from: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache + cache-to: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache,mode=max - - name: Run docker compose and apply nginx config - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.PRIVATE_KEY }} - script_stop: true - script: | - sudo cp /home/${{ secrets.USERNAME }}/solid-connection-prod/nginx/default.conf /etc/nginx/conf.d/default.conf - sudo nginx -t - sudo nginx -s reload + # --- 4. Github App으로 임시 토큰 생성 --- + - name: Create installation token + id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + # --- 5. 설정 파일들만 scp로 전송 --- + - name: Copy config files to remote + run: | + echo "${{ secrets.PRIVATE_KEY }}" > deploy_key.pem + chmod 600 deploy_key.pem + + scp -i deploy_key.pem \ + -o StrictHostKeyChecking=no \ + ./docker-compose.prod.yml \ + ./docs/infra-config/config.alloy \ + ./docs/infra-config/nginx.prod.conf \ + ${{ secrets.USERNAME }}@${{ secrets.HOST }}:/home/${{ secrets.USERNAME }}/solid-connection-prod/ + + # --- 6. 서버에서 'docker pull' 및 서비스 재시작 --- + - name: Run docker compose and apply nginx config + run: | + echo "${{ secrets.PRIVATE_KEY }}" > deploy_key_ssh.pem + chmod 600 deploy_key_ssh.pem + + ssh -i deploy_key_ssh.pem \ + -o StrictHostKeyChecking=no \ + ${{ secrets.USERNAME }}@${{ secrets.HOST }} \ + ' + set -e + + # 1. 변수 설정 + export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + export IMAGE_TAG_ONLY=${{ steps.image_meta.outputs.image_tag }} + export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG_ONLY}" + + # 2. 서버가 GHCR에 로그인 (pull 받기 위해) + echo "${{ steps.app.outputs.token }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin + + # 3. docker pull (전체 이미지 이름 사용) + echo "Pulling new image layer from GHCR..." + docker pull $FULL_IMAGE_NAME + + # 4. 작업 디렉토리로 이동 및 Nginx 설정 이동 + cd /home/${{ secrets.USERNAME }}/solid-connection-prod + mkdir -p ./nginx + mv ./nginx.prod.conf ./nginx/default.conf + + # 5. Nginx 재시작 + sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf + sudo nginx -t + sudo nginx -s reload + + # 6. Docker Compose 재시작 (안정성 확보 로직 포함) + echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" + echo "Stopping containers gracefully..." + docker compose -f docker-compose.prod.yml stop + + echo "Removing old containers and networks..." + docker compose -f docker-compose.prod.yml down --remove-orphans + + echo "Starting new containers..." + OWNER_LOWERCASE=$OWNER_LOWERCASE IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.prod.yml up -d + + # 7. 이미지 정리 + echo "Pruning dangling docker images..." + docker image prune -f + + echo "Deploy and Docker Compose restart finished." + ' - cd /home/${{ secrets.USERNAME }}/solid-connect-server - docker compose -f docker-compose.prod.yml down - docker compose -f docker-compose.prod.yml up -d --build + # --- 6. 이미지 정리 --- + - name: Clean up old image versions from GHCR + if: success() + uses: snok/container-retention-policy@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + image-names: solid-connection-server + delete-untagged: true + keep-n-tags: 5 + account-type: org + org-name: ${{ github.repository_owner }} + cut-off: '7 days ago UTC' diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e20302c4b..29aaf5bb1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -18,9 +18,7 @@ services: - redis solid-connection-dev: - build: - context: . - dockerfile: Dockerfile + image: ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG:-latest} container_name: solid-connection-dev ports: - "8080:8080" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d805031d0..5b26eecf9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,9 +18,7 @@ services: - redis solid-connection-server: - build: - context: . - dockerfile: Dockerfile + image: ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG:-latest} container_name: solid-connection-server ports: - "8080:8080" diff --git a/src/main/resources/secret b/src/main/resources/secret index 8300cdeca..2f4a16822 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 8300cdecaebfc28fd657064a00a44815a7bb2eee +Subproject commit 2f4a168223ea81cfe3447d6d95441b1a020fdbfe From 0f00f52b9e2386ee0b0f8ea33f5d386aa44d7b6f Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:19:07 +0900 Subject: [PATCH 04/31] =?UTF-8?q?fix:=20Github=20App=EC=9D=B4=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=ED=95=9C=20=EC=9E=84=EC=8B=9C=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=20=EC=9D=BD=EA=B8=B0=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EB=AA=85=EC=8B=9C=20(#565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 1 + .github/workflows/prod-cd.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 9a3ae34a4..a951b278e 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -73,6 +73,7 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permissions: "packages:read" # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index c96074d19..0ce68d8e5 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -71,6 +71,7 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permissions: "packages:read" # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote From 26490f5b06aa9a773b51005b1e7510eb0f2abe8d Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sat, 15 Nov 2025 23:22:06 +0900 Subject: [PATCH 05/31] =?UTF-8?q?fix:=20GitHub=20app=20token=20permission?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#566)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 --- .github/workflows/dev-cd.yml | 3 ++- .github/workflows/prod-cd.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index a951b278e..9de27ee19 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -73,7 +73,8 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - permissions: "packages:read" + permission-packages: "read" + permission-contents: "read" # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 0ce68d8e5..6c8869a61 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -71,7 +71,8 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - permissions: "packages:read" + permission-packages: "read" + permission-contents: "read" # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote From 57576ede088ef68a9343828a4d366efe15f2096a Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sat, 15 Nov 2025 23:56:15 +0900 Subject: [PATCH 06/31] =?UTF-8?q?fix:=20GitHub=20app=20token=20permission?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=20(#567)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 --- .github/workflows/dev-cd.yml | 3 +-- .github/workflows/prod-cd.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 9de27ee19..2d6ca3106 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -73,8 +73,7 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - permission-packages: "read" - permission-contents: "read" + permission-organization-packages: "read" # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 6c8869a61..73d57608c 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -71,8 +71,7 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - permission-packages: "read" - permission-contents: "read" + permission-organization-packages: "read" # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote From 66e2255aa646dfef845c9b93ae79560702739e0e Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sun, 16 Nov 2025 00:18:00 +0900 Subject: [PATCH 07/31] =?UTF-8?q?fix:=20GitHub=20app=20token=20permission?= =?UTF-8?q?=EC=9D=B4=20repo=20=EB=A0=88=EB=B2=A8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=80=EC=97=AC=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 --- .github/workflows/dev-cd.yml | 1 + .github/workflows/prod-cd.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 2d6ca3106..5ea6d6769 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -73,6 +73,7 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: 'solid-connection' permission-organization-packages: "read" # --- 5. 설정 파일들만 scp로 전송 --- diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 73d57608c..2146ce229 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -71,6 +71,7 @@ jobs: with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: 'solid-connection' permission-organization-packages: "read" # --- 5. 설정 파일들만 scp로 전송 --- From 76b25dac539d6d6ec35e96c723160f7d40179900 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:43:22 +0900 Subject: [PATCH 08/31] =?UTF-8?q?fix:=20GitHub=20app=20token=20permission?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=20(#569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * test: fork repo의 작업 branch에서 해당 workflows가 실행되도록 임시 수정 * refactor: test용 설정 제거 --- .github/workflows/dev-cd.yml | 2 +- .github/workflows/prod-cd.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 5ea6d6769..7fe433a56 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -74,7 +74,7 @@ jobs: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: 'solid-connection' - permission-organization-packages: "read" + permission-packages: "read" # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 2146ce229..3272deb9e 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -72,7 +72,8 @@ jobs: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: 'solid-connection' - permission-organization-packages: "read" + permission-packages: "read" + # --- 5. 설정 파일들만 scp로 전송 --- - name: Copy config files to remote From 3134f762c53a2f6b662182c1231e9b47c0159247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EA=B7=9C=ED=98=81?= <126947828+Gyuhyeok99@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:21:21 +0900 Subject: [PATCH 09/31] =?UTF-8?q?feat:=20claude.md=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#560)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- claude.md | 619 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 619 insertions(+) create mode 100644 claude.md diff --git a/claude.md b/claude.md new file mode 100644 index 000000000..ddd2cb9b4 --- /dev/null +++ b/claude.md @@ -0,0 +1,619 @@ + +# CLAUDE.md + +이 파일은 Claude Code가 solid-connect-server 저장소에서 작업할 때 참고하는 가이드입니다. + +## 프로젝트 개요 + +Solid Connect Server는 교환학생 준비생을 위해 대학 정보, 멘토 매칭, 모의지원 기능 등을 제공하는 교환학생 지원 통합 플랫폼입니다. + +- **언어**: Java 17 +- **프레임워크**: Spring Boot 3.1.5 +- **빌드 도구**: Gradle +- **데이터베이스**: MySQL (주), Redis (캐싱) +- **마이그레이션**: Flyway + +--- + +## 빌드 및 개발 명령어 + +### Gradle 빌드 명령어 + +```bash +# 전체 빌드 +./gradlew build + +# 테스트 실행 +./gradlew test + +# 특정 테스트만 실행 +./gradlew test --tests ChatServiceTest + +# 애플리케이션 실행 +./gradlew bootRun + +# 로컬 개발 환경 시작 (MySQL, Redis) +docker-compose -f docker-compose.local.yml up -d +``` + +### 프로필별 실행 + +```bash +# 로컬 개발 환경 +./gradlew bootRun --args='--spring.profiles.active=local' + +# 개발 환경 +./gradlew bootRun --args='--spring.profiles.active=dev' + +# 운영 환경 +./gradlew bootRun --args='--spring.profiles.active=prod' +``` + +--- + +## 프로젝트 구조 + +``` +solid-connect-server/ +├── src/ +│ ├── main/ +│ │ ├── java/com/example/solidconnection/ +│ │ │ ├── [domain]/ # 도메인별 폴더 +│ │ │ │ ├── controller/ # REST API 엔드포인트 +│ │ │ │ ├── service/ # 비즈니스 로직 +│ │ │ │ ├── domain/ # JPA Entity +│ │ │ │ ├── repository/ # 데이터 접근 계층 +│ │ │ │ └── dto/ # DTO (Request/Response) +│ │ │ └── common/ # 공통 기능 +│ │ │ ├── exception/ # 커스텀 예외 +│ │ │ ├── config/ # Spring 설정 +│ │ │ └── util/ # 유틸리티 +│ │ └── resources/ +│ │ ├── db/migration/ # Flyway 마이그레이션 +│ │ └── application*.yml # 설정 파일 +│ └── test/ +│ └── java/com/example/solidconnection/ +│ ├── [domain]/fixture/ # 테스트 Fixture +│ ├── [domain]/service/ # 서비스 테스트 +│ ├── support/ # 테스트 설정 +│ └── ... +├── docker-compose.local.yml # 로컬 컨테이너 +├── docker-compose.dev.yml # 개발 컨테이너 +├── docker-compose.prod.yml # 운영 컨테이너 +├── Dockerfile # 이미지 빌드 +├── build.gradle # Gradle 설정 +``` + +--- + +## 아키텍처 + +### 계층형 아키텍처 (Layered Architecture) + +각 계층은 자신의 바로 아래 계층만 참조할 수 있습니다. + +``` +Controller → Service → Repository/Domain +``` + +**각 계층의 역할:** + +- **Controller**: HTTP 요청 처리, 입력값 검증, 응답 포맷팅 +- **Service**: 비즈니스 로직 처리, DTO 변환, 트랜잭션 관리 +- **Repository**: 데이터 접근 계층, DB 쿼리 작성 +- **Domain (Entity)**: JPA 엔티티, 도메인 모델 + +**주요 규칙:** + +- ✅ 역계층 참조 금지 (예: Repository에서 Service 참조 불가) +- ✅ Service는 Repository를 주입받아 사용 +- ✅ Controller는 Service를 주입받아 사용 +- ✅ Entity는 도메인 로직만 포함 +- ✅ DTO는 요청/응답 시에만 사용 + +### 패키지 구조 + +``` +[domain]/ +├── controller/ # REST API 엔드포인트 +├── service/ # 비즈니스 로직 (Service) +├── domain/ # JPA Entity +├── repository/ # 데이터 접근 계층 (Repository) +└── dto/ # DTO (Request/Response) +``` + +--- + +## 개발 컨벤션 + +### 코드 스타일 + +프로젝트의 개발 컨벤션을 따릅니다: [개발-컨벤션-정리](https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리) + +**주요 규칙:** + +- **클래스 선언 전 줄바꿈**: 클래스 정의 앞에 빈 줄 필수 +- **파일 끝 줄바꿈**: 모든 파일은 개행 문자로 종료 +- **와일드카드 import 금지**: 명시적 import만 사용 +- **파라미터 줄바꿈**: Controller는 필수, 3개 이상의 파라미터가 있으면 줄바꿈 +- **private 메서드 위치**: 호출하는 public 메서드 바로 아래 위치 +- **원시 타입 사용**: null이 아닌 값은 `int`, `long` 등 원시 타입 사용, nullable은 Wrapper 사용 +- **JPA @Column**: Entity의 모든 필드에 `@Column` 속성과 필드명 지정 + +### 네이밍 컨벤션 + +```java +// DTO 변환 +// 다중 파라미터: of() 메서드 +public static UserDto of(User user, Profile profile) { ... } + +// 단일 파라미터: from() 메서드 +public static UserDto from(User user) { ... } + +// API 요청/응답 +// XXXRequest, XXXResponse 형식 +public class UserCreateRequest { ... } +public class UserCreateResponse { ... } + +// REST API 엔드포인트 +// kebab-case 사용 +@GetMapping("/user-profile") // O +@GetMapping("/userProfile") // X +``` + +--- + +## 기술 스택 상세 + +### Core Framework + +- **Spring Boot 3.1.5**: 스프링 부트 +- **Spring Security**: JWT 기반 인증 +- **Spring Data JPA**: ORM +- **QueryDSL**: 동적 쿼리 생성 + +### 데이터베이스 + +- **MySQL**: 주 데이터베이스 +- **Redis**: 캐싱 저장소 +- **Flyway**: 데이터베이스 버전 관리 + +### 모니터링 & 보안 + +- **Spring Boot Actuator**: 애플리케이션 모니터링 +- **Prometheus**: 메트릭 수집 +- **Sentry**: 에러 추적 +- **JWT**: JWT 토큰 관리 + +### 개발 도구 + +- **Lombok**: 보일러플레이트 코드 감소 +- **AWS S3 SDK**: 파일 저장소 +- **WebSocket**: 실시간 통신 +- **TestContainers**: 통합 테스트용 컨테이너 + +--- + +## 테스트 코드 작성 + +### 테스트 기본 설정 + +모든 통합 테스트는 `@TestContainerSpringBootTest` 어노테이션을 사용합니다. + +```java +@TestContainerSpringBootTest +@DisplayName("채팅 서비스 테스트") +class ChatServiceTest { + // 테스트 코드 +} +``` + +**제공 기능:** +- MySQL, Redis 자동 실행 +- Spring Boot 컨텍스트 로드 +- 테스트 후 자동 DB 초기화 +- JUnit 5 기반 + +### Fixture 패턴 + +테스트 데이터는 Fixture로 생성합니다 (FixtureBuilder + Fixture 패턴). + +**위치:** `src/test/java/com/example/solidconnection/[domain]/fixture/` + +``` +fixture/ +├── [Entity]FixtureBuilder.java # Builder 패턴 구현 +└── [Entity]Fixture.java # 편의 메서드 제공 +``` + +#### 예제: ChatRoomFixtureBuilder + +```java +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixtureBuilder { + + private final ChatRoomRepository chatRoomRepository; + + private boolean isGroup; + private Long mentoringId; + + public ChatRoomFixtureBuilder chatRoom() { + return new ChatRoomFixtureBuilder(chatRoomRepository); + } + + public ChatRoomFixtureBuilder isGroup(boolean isGroup) { + this.isGroup = isGroup; + return this; + } + + public ChatRoomFixtureBuilder mentoringId(long mentoringId) { + this.mentoringId = mentoringId; + return this; + } + + public ChatRoom create() { + ChatRoom chatRoom = new ChatRoom(mentoringId, isGroup); + return chatRoomRepository.save(chatRoom); // DB 저장 + } +} +``` + +#### 예제: ChatRoomFixture + +```java +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixture { + + private final ChatRoomFixtureBuilder chatRoomFixtureBuilder; + + // 편의 메서드: 기본값으로 생성 + public ChatRoom 채팅방(boolean isGroup) { + return chatRoomFixtureBuilder.chatRoom() + .isGroup(isGroup) + .create(); + } + + public ChatRoom 멘토링_채팅방(long mentoringId) { + return chatRoomFixtureBuilder.chatRoom() + .mentoringId(mentoringId) + .isGroup(false) + .create(); + } +} +``` + +**편의 메서드 작성 팁:** + +- 한국어 메서드명 사용 (가독성) +- 자주 사용되는 기본값 조합만 제공 +- Builder를 조합하여 필요한 데이터 설정 + + +#### 테스트에서 사용 + +```java +@TestContainerSpringBootTest +class ChatServiceTest { + + @Autowired + private ChatRoomFixture chatRoomFixture; + + @Test + void 채팅방을_생성할_수_있다() { + // 편의 메서드 사용 + ChatRoom room = chatRoomFixture.채팅방(false); + + // Builder 직접 사용 + ChatRoom customRoom = chatRoomFixture.chatRoomFixtureBuilder.chatRoom() + .isGroup(true) + .mentoringId(100L) + .create(); + } +} +``` + +## 테스트 네이밍 컨벤션 + +### 테스트 메서드 네이밍 규칙 + +테스트 메서드명은 **한국어로 명확하게** 작성하며, 다음 패턴을 따릅니다: + +#### 1. 정상 동작 테스트 + +```java +// 패턴: 어떤_것을_하면_어떤_결과가_나온다 +@Test +void 채팅방이_없으면_빈_목록을_반환한다() { ... } + +@Test +void 최신_메시지_순으로_정렬되어_조회한다() { ... } + +@Test +void 참여자는_메시지를_전송할_수_있다() { ... } + +@Test +void 페이징이_정상_작동한다() { ... } +``` + +#### 2. 예외 테스트 + +```java +// 패턴: 어떤_것을_하면_예외_응답을_반환한다 +@Test +void 참여하지_않은_채팅방에_접근하면_예외_응답을_반환한다() { ... } + +@Test +void 존재하지_않는_사용자로_메시지를_전송하면_예외_응답을_반환한다() { ... } + +@Test +void 권한이_없으면_예외_응답을_반환한다() { ... } + +@Test +void 필수_파라미터가_없으면_예외_응답을_반환한다() { ... } +``` + + +### BDD 테스트 작성 + +테스트는 Given-When-Then 구조로 작성합니다. + +```java +@Test +@DisplayName("최신 메시지순으로 채팅방 목록을 조회한다") +void 최신_메시지_순으로_조회한다() { + // Given: 테스트 사전 조건 + SiteUser user = siteUserFixture.사용자(); + ChatRoom room1 = chatRoomFixture.채팅방(false); + ChatRoom room2 = chatRoomFixture.채팅방(false); + chatMessageFixture.메시지("오래된 메시지", user.getId(), room1); + chatMessageFixture.메시지("최신 메시지", user.getId(), room2); + + // When: 실제 동작 + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // Then: 결과 검증 + assertAll( + () -> assertThat(response.chatRooms()).hasSize(2), + () -> assertThat(response.chatRooms().get(0).id()).isEqualTo(room2.getId()) + ); +} +``` + +### 테스트 그룹화 (@Nested) + +기능별로 테스트를 그룹화합니다. + +```java +@TestContainerSpringBootTest +class ChatServiceTest { + + @Nested + @DisplayName("채팅방 목록 조회") + class 채팅방_목록을_조회한다 { + + @Test + void 빈_목록을_반환한다() { ... } + + @Test + void 최신_메시지_순으로_조회한다() { ... } + } + + @Nested + @DisplayName("채팅 메시지 전송") + class 채팅_메시지를_전송한다 { + + @BeforeEach + void setUp() { + // 이 그룹에만 적용되는 초기 설정 + } + + @Test + void 참여자는_메시지를_전송할_수_있다() { ... } + } +} +``` + +### 자주 사용하는 Assertion + +```java +// 기본 검증 +assertThat(value).isEqualTo(expected); +assertThat(value).isNotNull(); + +// 컬렉션 +assertThat(list).hasSize(3); +assertThat(list).isEmpty(); +assertThat(list).contains(item); + +// 예외 검증 +assertThatCode(() -> method()) + .isInstanceOf(CustomException.class) + .hasMessage("error message"); + +// 복수 검증 +assertAll( + () -> assertThat(a).isEqualTo(1), + () -> assertThat(b).isEqualTo(2) +); +``` + +--- + +## Git 커밋 컨벤션 + +### 형식 + +``` +: + +[optional body] +``` + +### Type 목록 + +``` +feat: 새로운 기능 추가 +fix: 버그 수정 +refactor: 코드 리팩토링 (기능 변경 없음) +docs: 문서 변경 +test: 테스트 추가/수정 +chore: 빌드 설정, 패키지 관리 +perf: 성능 개선 +``` + +### 예제 + +```bash +# 기능 추가 +feat: 대학 검색 기능 추가 + +# 버그 수정 +fix: 채팅방 조회 시 정렬 버그 수정 + +# 리팩토링 +refactor: ChatService 메서드 분리 + +# 테스트 추가 +test: ChatService 테스트 케이스 추가 + +# 브랜치명 +refactor/529-shortening-cd-time +``` + +--- + +## 데이터베이스 마이그레이션 + +### Flyway 사용 + +모든 DB 스키마 변경사항은 Flyway로 관리합니다. + +**위치:** `src/main/resources/db/migration/` + +**파일명 형식:** `V{VERSION}__{DESCRIPTION}.sql` + +``` +V1__init_schema.sql +V2__add_chat_table.sql +V3__add_user_role_column.sql +``` + +### 마이그레이션 추가 + +1. `V{next_version}__{description}.sql` 파일 생성 +2. SQL 작성 +3. `./gradlew build` 시 자동 검증 (flywayValidate) + +**주의:** 한 번 배포된 마이그레이션은 수정 불가 (새 버전으로 생성) + +--- + +## 데이터베이스 접근 + +### JPA Entity + +```java +@Entity +@Table(name = "chat_room") +public class ChatRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "is_group", nullable = false) + private boolean isGroup; + + @Column(name = "mentoring_id", nullable = true) + private Long mentoringId; +} +``` + +**규칙:** +- `@Column` 필수 (모든 필드) +- 필드명과 DB 컬럼명 일치 +- nullable 명시 + +### Repository + +```java +public interface ChatRoomRepository extends JpaRepository { + Optional findByMentoringId(Long mentoringId); + List findByIsGroup(boolean isGroup); +} +``` + +--- + +## 주요 파일 위치 + +| 파일/폴더 | 설명 | +|----------|------| +| `src/main/java/com/example/solidconnection/` | 메인 소스 코드 | +| `src/test/java/com/example/solidconnection/` | 테스트 코드 | +| `src/main/resources/db/migration/` | Flyway 마이그레이션 | +| `src/main/resources/application.yml` | 공통 설정 | +| `docker-compose.*.yml` | 환경별 도커 설정 | +| `build.gradle` | Gradle 빌드 설정 | + +--- + +### 프로필 +- **local**: Development with embedded Tomcat +- **dev**: Development server (stage.solid-connection.com) +- **prod**: Production server (solid-connection.com) + +--- + +## 자주하는 작업 + +### 새 기능 추가 + +1. Entity 생성 (`src/main/java/.../domain/`) +2. Repository 작성 (`src/main/java/.../repository/`) +3. Service 구현 (`src/main/java/.../service/`) +4. Controller 작성 (`src/main/java/.../controller/`) +5. DTO 정의 (`src/main/java/.../dto/`) +6. Flyway 마이그레이션 작성 +7. 테스트 코드 작성 + +### 테스트 작성 + +1. FixtureBuilder 생성 (필요시) +2. Fixture 편의 메서드 추가 (필요시) +3. 테스트 클래스 작성 (`*Test.java`) +4. @Nested로 테스트 그룹화 +5. Given-When-Then 구조로 작성 +6. `./gradlew test` 실행 + +### DB 스키마 변경 + +1. `V{next}__{description}.sql` 파일 생성 +2. 마이그레이션 SQL 작성 +3. Entity 업데이트 (필요시) +4. 테스트 실행 + +--- + +## 참고 자료 + +- **개발 컨벤션**: https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리 +- **테스트 가이드**: `test.md` 파일 참고 +- **Spring Boot**: https://spring.io/projects/spring-boot +- **JPA**: https://spring.io/projects/spring-data-jpa +- **TestContainers**: https://www.testcontainers.org/ +- **Flyway**: https://flywaydb.org/ + +--- + + +## 주의사항 + +1. **Flyway 마이그레이션은 되돌릴 수 없음** - 신중하게 작성 +2. **QueryDSL Q클래스는 자동 생성** - 수동 수정 금지 +3. **테스트는 독립적** - 테스트 간 데이터 공유 불가 +4. **환경별 설정 분리** - application-local.yml, application-dev.yml, application-prod.yml +5. **한국어 메서드명** - 테스트 가독성 향상을 위해 사용 From f5c4034cd2cc6c657999de6e57c9bd6ac85968f4 Mon Sep 17 00:00:00 2001 From: Yeon <84384499+lsy1307@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:04:30 +0900 Subject: [PATCH 10/31] =?UTF-8?q?fix=20:=20=EB=8F=99=EC=9D=BC=20=EB=A9=98?= =?UTF-8?q?=ED=86=A0=20=EB=A9=98=ED=8B=B0=20=EC=A4=91=EB=B3=B5=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EB=B6=88=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#563)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : 동일 멘토 멘티 중복 신청 불가능하도록 수정 - UK 제약조건 추가 - flyway script 추가 - CustomException 추가 - Service 로직 수정 - Test code 추가 * fix : column명 오류 수정 - column명 camelCase -> snake_case로 변경 * fix : column명 오류 수정 - column명 name으로 명시 --- .../common/exception/ErrorCode.java | 1 + .../mentor/domain/Mentoring.java | 20 +++++++++++++------ .../service/MentoringCommandService.java | 8 +++++++- ...ique_constraint_to_mentor_id_mentee_id.sql | 3 +++ .../service/MentoringCommandServiceTest.java | 13 ++++++++++++ 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/migration/V37__add_unique_constraint_to_mentor_id_mentee_id.sql diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index fae3c4980..17661fe2d 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -118,6 +118,7 @@ public enum ErrorCode { CHANNEL_SEQUENCE_NOT_UNIQUE(HttpStatus.BAD_REQUEST.value(), "채널의 순서가 중복되었습니다."), CHANNEL_REGISTRATION_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "등록 가능한 채널 수를 초과하였습니다."), ALREADY_MENTOR(HttpStatus.BAD_REQUEST.value(), "이미 멘토로 등록된 사용자입니다."), + ALREADY_EXIST_MENTORING(HttpStatus.BAD_REQUEST.value(), "이미 신청된 멘티, 멘토입니다."), MENTORING_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 멘토링 신청을 찾을 수 없습니다."), UNAUTHORIZED_MENTORING(HttpStatus.FORBIDDEN.value(), "멘토링 권한이 없습니다."), MENTORING_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토링입니다."), diff --git a/src/main/java/com/example/solidconnection/mentor/domain/Mentoring.java b/src/main/java/com/example/solidconnection/mentor/domain/Mentoring.java index a839d8122..4dcae29bf 100644 --- a/src/main/java/com/example/solidconnection/mentor/domain/Mentoring.java +++ b/src/main/java/com/example/solidconnection/mentor/domain/Mentoring.java @@ -13,6 +13,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.ZonedDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -27,29 +29,35 @@ @DynamicInsert @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mentoring", uniqueConstraints = { + @UniqueConstraint( + name = "uk_mentoring_mentor_id_mentee_id", + columnNames = {"mentor_id", "mentee_id"} + ) +}) public class Mentoring extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column + @Column(name="confirmed_at") private ZonedDateTime confirmedAt; - @Column + @Column(name = "checked_at_by_mentor") private ZonedDateTime checkedAtByMentor; - @Column + @Column(name = "checked_at_by_mentee") private ZonedDateTime checkedAtByMentee; - @Column(nullable = false) + @Column(nullable = false, name="verify_status") @Enumerated(EnumType.STRING) private VerifyStatus verifyStatus = VerifyStatus.PENDING; - @Column + @Column(name = "mentor_id") private long mentorId; - @Column + @Column(name = "mentee_id") private long menteeId; public Mentoring(long mentorId, long menteeId, VerifyStatus verifyStatus) { diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentoringCommandService.java b/src/main/java/com/example/solidconnection/mentor/service/MentoringCommandService.java index 254323127..884526c1b 100644 --- a/src/main/java/com/example/solidconnection/mentor/service/MentoringCommandService.java +++ b/src/main/java/com/example/solidconnection/mentor/service/MentoringCommandService.java @@ -1,5 +1,6 @@ package com.example.solidconnection.mentor.service; +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_EXIST_MENTORING; import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_ALREADY_CONFIRMED; import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; @@ -30,8 +31,13 @@ public class MentoringCommandService { @Transactional public MentoringApplyResponse applyMentoring(long siteUserId, MentoringApplyRequest mentoringApplyRequest) { - Mentoring mentoring = new Mentoring(mentoringApplyRequest.mentorId(), siteUserId, VerifyStatus.PENDING); + long mentorId = mentoringApplyRequest.mentorId(); + + if (mentoringRepository.existsByMentorIdAndMenteeId(mentorId, siteUserId)) { + throw new CustomException(ALREADY_EXIST_MENTORING); + } + Mentoring mentoring = new Mentoring(mentoringApplyRequest.mentorId(), siteUserId, VerifyStatus.PENDING); return MentoringApplyResponse.from(mentoringRepository.save(mentoring)); } diff --git a/src/main/resources/db/migration/V37__add_unique_constraint_to_mentor_id_mentee_id.sql b/src/main/resources/db/migration/V37__add_unique_constraint_to_mentor_id_mentee_id.sql new file mode 100644 index 000000000..98968fbb1 --- /dev/null +++ b/src/main/resources/db/migration/V37__add_unique_constraint_to_mentor_id_mentee_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE mentoring +ADD CONSTRAINT uk_mentoring_mentor_id_mentee_id +UNIQUE (mentor_id, mentee_id); diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentoringCommandServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentoringCommandServiceTest.java index 8c6a78468..002dfa6a5 100644 --- a/src/test/java/com/example/solidconnection/mentor/service/MentoringCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/mentor/service/MentoringCommandServiceTest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.mentor.service; +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_EXIST_MENTORING; import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_ALREADY_CONFIRMED; import static com.example.solidconnection.common.exception.ErrorCode.MENTORING_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.UNAUTHORIZED_MENTORING; @@ -96,6 +97,18 @@ class 멘토링_신청_테스트 { () -> assertThat(mentoring.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING) ); } + + @Test + void 동일_멘티_멘토끼리는_재신청되지않는다() { + // given + mentoringFixture.대기중_멘토링(mentor1.getId(), menteeUser.getId()); + MentoringApplyRequest request = new MentoringApplyRequest(mentor1.getId()); + + // when & then + assertThatThrownBy(() -> mentoringCommandService.applyMentoring(menteeUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_EXIST_MENTORING.getMessage()); + } } @Nested From f3fb02d5ca28f1fa625e86c0df83e3a99802d91e Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:34:54 +0900 Subject: [PATCH 11/31] =?UTF-8?q?fix:=20GitHub=20app=20token=20permission?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=20(#570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * test: fork repo의 작업 branch에서 해당 workflows가 실행되도록 임시 수정 * refactor: test용 설정 제거 * fix: docker login username 불일치 문제 * refactor: 최소권한 원칙 적용을 위한 Action Job 분리 * refactor: 필요없는 주석 제거 --- .github/workflows/dev-cd.yml | 104 ++++++++++++----------- .github/workflows/prod-cd.yml | 153 +++++++++++++++++++--------------- 2 files changed, 140 insertions(+), 117 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 7fe433a56..3e8d994f6 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -6,12 +6,16 @@ on: workflow_dispatch: jobs: - build-gradle: + # --- Job 1: 빌드 및 이미지 푸시 (쓰기 권한 필요) --- + build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write + outputs: + image_tag: ${{ steps.image_meta.outputs.image_tag }} + steps: - name: Checkout the code uses: actions/checkout@v4 @@ -27,7 +31,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - - name: Grant execute permission for Gradle wrapper(gradlew) + - name: Grant execute permission for Gradle wrapper run: chmod +x ./gradlew - name: Build with Gradle run: ./gradlew bootJar @@ -37,15 +41,15 @@ jobs: uses: docker/setup-buildx-action@v3 with: platforms: linux/arm64 + - name: Log in to GitHub Container Registry (GHCR) uses: docker/login-action@v3 with: registry: ghcr.io - username: ${{ github.repository_owner }} + username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # --- 2. 이미지 메타데이터(이름, 태그) 정의 --- - # 빌드/푸시 단계와 SSH 단계에서 공통으로 사용할 변수를 미리 정의합니다. + # --- 이미지 메타데이터 정의 --- - name: Define image name and tag id: image_meta run: | @@ -54,8 +58,7 @@ jobs: echo "image_name=ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev" >> $GITHUB_OUTPUT echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT - # --- 3. Docker 이미지 빌드, 푸시, 캐시 --- - # 'docker/build-push-action'을 사용하여 캐시 옵션을 적용합니다. + # --- Docker 빌드 및 푸시 --- - name: Build, push, and cache Docker image uses: docker/build-push-action@v5 with: @@ -66,17 +69,37 @@ jobs: cache-from: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache cache-to: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache,mode=max - # --- 4. Github App으로 임시 토큰 생성 --- - - name: Create installation token - id: app - uses: actions/create-github-app-token@v2 + # --- 이미지 정리 (이전 Job에 있던 것) --- + - name: Clean up old image versions from GHCR + uses: snok/container-retention-policy@v2 with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - owner: 'solid-connection' - permission-packages: "read" + token: ${{ secrets.GITHUB_TOKEN }} + image-names: solid-connection-dev + delete-untagged: true + keep-n-tags: 5 + account-type: org + org-name: ${{ github.repository_owner }} + cut-off: '7 days ago UTC' + + # --- Job 2: 배포 (읽기 권한만 필요) --- + deploy: + needs: build-and-push + runs-on: ubuntu-latest + permissions: + contents: read + packages: read - # --- 5. 설정 파일들만 scp로 전송 --- + steps: + # 설정 파일 전송을 위해 코드 체크아웃 (서브모듈 불필요) + - name: Checkout config files + uses: actions/checkout@v4 + with: + sparse-checkout: | + docker-compose.dev.yml + docs/infra-config + sparse-checkout-cone-mode: false + + # --- 설정 파일 전송 --- - name: Copy config files to remote run: | echo "${{ secrets.DEV_PRIVATE_KEY }}" > deploy_key.pem @@ -89,67 +112,52 @@ jobs: ./docs/infra-config/nginx.dev.conf \ ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }}:/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/ - # --- 6. 서버에서 'docker pull' 및 서비스 재시작 --- + # --- 서버에서 Docker Pull 및 재시작 --- - name: Run docker compose and apply nginx config run: | + # GITHUB_TOKEN을 이용해 서버에서 로그인 (App Token 불필요) ssh -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }} \ ' set -e - # 1. 변수를 'image_meta' 단계의 출력값에서 가져옴 - export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - export IMAGE_TAG_ONLY=${{ steps.image_meta.outputs.image_tag }} + # 1. 환경 변수 설정 (이전 Job의 Output 사용) + export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + export IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG_ONLY}" - # 2. 서버가 GHCR에 로그인 (pull 받기 위해) - echo "${{ steps.app.outputs.token }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin + # 2. 서버가 GHCR에 로그인 (GITHUB_TOKEN 사용) + # App Token 대신 현재 워크플로우의 임시 토큰을 넘겨줍니다. + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - # 3. docker pull (전체 이미지 이름 사용) - echo "Pulling new image layer from GHCR..." + # 3. Docker Pull + echo "Pulling new image: $FULL_IMAGE_NAME" docker pull $FULL_IMAGE_NAME - # 4. 작업 디렉토리로 이동 및 Nginx 설정 이동 + # 4. 작업 및 Nginx 설정 적용 cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev mkdir -p ./nginx mv ./nginx.dev.conf ./nginx/default.conf - - # 5. Nginx 재시작 sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf sudo nginx -t sudo nginx -s reload - # 6. Docker Compose 재시작 + # 5. Docker Compose 재시작 echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" docker compose -f docker-compose.dev.yml down IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.dev.yml up -d - # 7. 이미지 정리 - echo "Pruning dangling docker images..." + # 6. 정리 작업 + echo "Pruning dangling images..." docker image prune -f - # 8. stage 인스턴스의 오래된 태그 이미지 정리 (최신 5개 유지) - echo "Cleaning up old tagged images on host, keeping last 5..." + echo "Cleaning up old tagged images (keeping last 5)..." IMAGE_NAME_BASE="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev" - docker images "${IMAGE_NAME_BASE}" --format "{{.Tag}}" | \ sort -r | \ tail -n +6 | \ xargs -I {} docker rmi "${IMAGE_NAME_BASE}:{}" || true - echo "Deploy and Docker Compose restart finished." - ' - - # --- 6. 이미지 정리 --- - - name: Clean up old image versions from GHCR - if: success() - uses: snok/container-retention-policy@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - image-names: solid-connection-dev - delete-untagged: true - keep-n-tags: 5 - account-type: org - org-name: ${{ github.repository_owner }} - cut-off: '7 days ago UTC' + echo "Deployment finished successfully." + ' \ No newline at end of file diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 3272deb9e..d71f832a3 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -1,17 +1,27 @@ name: "[PROD] Build Gradle and Deploy" on: - push: - branches: [ "master" ] + release: + types: [published] + workflow_dispatch: + inputs: + tag_name: + description: 'Docker Tag Name (e.g., v1.0.0)' + required: true + default: 'latest' jobs: - build-gradle: + # --- Job 1: 빌드 및 이미지 푸시 (쓰기 권한) --- + build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write + outputs: + image_tag: ${{ steps.image_meta.outputs.image_tag }} + steps: - name: Checkout the code uses: actions/checkout@v4 @@ -19,7 +29,7 @@ jobs: token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} submodules: true - # --- Java, Gradle 설정 (dev와 동일하게 버전 통일) --- + # --- Java, Gradle 설정 --- - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -27,33 +37,44 @@ jobs: distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - - name: Grant execute permission for Gradle wrapper(gradlew) + - name: Grant execute permission for Gradle wrapper run: chmod +x ./gradlew - name: Build with Gradle run: ./gradlew bootJar - # --- 1. Docker 설정 --- + # --- Docker 설정 --- - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: platforms: linux/arm64 + - name: Log in to GitHub Container Registry (GHCR) uses: docker/login-action@v3 with: registry: ghcr.io - username: ${{ github.repository_owner }} + username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # --- 2. 이미지 메타데이터(이름, 태그) 정의 --- + # --- 이미지 메타데이터 정의 (Prod용 이미지 이름) --- - name: Define image name and tag id: image_meta run: | OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - IMAGE_TAG=$(date +'%Y%m%d-%H%M%S') + + # Trigger가 Release인 경우: Release의 Tag Name (예: v1.0.0) 사용 + if [ "${{ github.event_name }}" == "release" ]; then + IMAGE_TAG="${{ github.ref_name }}" + # Trigger가 수동(workflow_dispatch)인 경우: 입력받은 tag_name 사용 + else + IMAGE_TAG="${{ inputs.tag_name }}" + fi + + echo "Docker Image Tag: $IMAGE_TAG" + echo "image_name=ghcr.io/${OWNER_LOWERCASE}/solid-connection-server" >> $GITHUB_OUTPUT echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT - # --- 3. Docker 이미지 빌드, 푸시, 캐시 --- + # --- Docker 빌드 및 푸시 --- - name: Build, push, and cache Docker image uses: docker/build-push-action@v5 with: @@ -64,91 +85,85 @@ jobs: cache-from: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache cache-to: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache,mode=max - # --- 4. Github App으로 임시 토큰 생성 --- - - name: Create installation token - id: app - uses: actions/create-github-app-token@v2 + # --- 이미지 정리 --- + - name: Clean up old image versions from GHCR + uses: snok/container-retention-policy@v2 with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - owner: 'solid-connection' - permission-packages: "read" + token: ${{ secrets.GITHUB_TOKEN }} + image-names: solid-connection-server + delete-untagged: true + keep-n-tags: 5 + account-type: org + org-name: ${{ github.repository_owner }} + cut-off: '7 days ago UTC' - - # --- 5. 설정 파일들만 scp로 전송 --- + # --- Job 2: 배포 (읽기 권한) --- + deploy: + needs: build-and-push + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + + steps: + # 설정 파일 전송을 위해 코드 체크아웃 (서브모듈 불필요) + - name: Checkout config files + uses: actions/checkout@v4 + with: + sparse-checkout: | + docker-compose.prod.yml + docs/infra-config + sparse-checkout-cone-mode: false + + # --- 설정 파일 전송 --- - name: Copy config files to remote run: | echo "${{ secrets.PRIVATE_KEY }}" > deploy_key.pem chmod 600 deploy_key.pem - + scp -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ./docker-compose.prod.yml \ ./docs/infra-config/config.alloy \ ./docs/infra-config/nginx.prod.conf \ ${{ secrets.USERNAME }}@${{ secrets.HOST }}:/home/${{ secrets.USERNAME }}/solid-connection-prod/ - - # --- 6. 서버에서 'docker pull' 및 서비스 재시작 --- + + # --- 서버에서 Docker Pull 및 재시작 --- - name: Run docker compose and apply nginx config run: | - echo "${{ secrets.PRIVATE_KEY }}" > deploy_key_ssh.pem - chmod 600 deploy_key_ssh.pem - - ssh -i deploy_key_ssh.pem \ + ssh -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ${{ secrets.USERNAME }}@${{ secrets.HOST }} \ ' set -e - - # 1. 변수 설정 - export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - export IMAGE_TAG_ONLY=${{ steps.image_meta.outputs.image_tag }} + + # 1. 변수 설정 (이전 Job의 Output 사용) + export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + export IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG_ONLY}" - - # 2. 서버가 GHCR에 로그인 (pull 받기 위해) - echo "${{ steps.app.outputs.token }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin - - # 3. docker pull (전체 이미지 이름 사용) - echo "Pulling new image layer from GHCR..." + + # 2. 서버가 GHCR에 로그인 (GITHUB_TOKEN 사용) + # App Token 대신 현재 워크플로우의 임시 토큰을 사용합니다. + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + # 3. docker pull + echo "Pulling new image: $FULL_IMAGE_NAME" docker pull $FULL_IMAGE_NAME - - # 4. 작업 디렉토리로 이동 및 Nginx 설정 이동 + + # 4. Nginx 설정 적용 cd /home/${{ secrets.USERNAME }}/solid-connection-prod mkdir -p ./nginx mv ./nginx.prod.conf ./nginx/default.conf - - # 5. Nginx 재시작 sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf sudo nginx -t sudo nginx -s reload - - # 6. Docker Compose 재시작 (안정성 확보 로직 포함) + + # 5. Docker Compose 재시작 echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" - echo "Stopping containers gracefully..." - docker compose -f docker-compose.prod.yml stop - - echo "Removing old containers and networks..." - docker compose -f docker-compose.prod.yml down --remove-orphans - - echo "Starting new containers..." + docker compose -f docker-compose.prod.yml down OWNER_LOWERCASE=$OWNER_LOWERCASE IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.prod.yml up -d - - # 7. 이미지 정리 - echo "Pruning dangling docker images..." - docker image prune -f - - echo "Deploy and Docker Compose restart finished." - ' - # --- 6. 이미지 정리 --- - - name: Clean up old image versions from GHCR - if: success() - uses: snok/container-retention-policy@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - image-names: solid-connection-server - delete-untagged: true - keep-n-tags: 5 - account-type: org - org-name: ${{ github.repository_owner }} - cut-off: '7 days ago UTC' + # 6. 정리 + docker image prune -f + echo "Deployment finished successfully." + ' \ No newline at end of file From 4da854bfa5462b01d7248ee9a2950b51dd57c5a8 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:13:59 +0900 Subject: [PATCH 12/31] =?UTF-8?q?fix:=20GHCR=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20PAT=EB=A1=9C=20=ED=95=B4=EA=B2=B0=20(#573)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 2 +- .github/workflows/prod-cd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 3e8d994f6..38a274143 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -73,7 +73,7 @@ jobs: - name: Clean up old image versions from GHCR uses: snok/container-retention-policy@v2 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.PACKAGE_DELETE_TOKEN }} image-names: solid-connection-dev delete-untagged: true keep-n-tags: 5 diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index d71f832a3..e05e1d0ac 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -89,7 +89,7 @@ jobs: - name: Clean up old image versions from GHCR uses: snok/container-retention-policy@v2 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.PACKAGE_DELETE_TOKEN }} image-names: solid-connection-server delete-untagged: true keep-n-tags: 5 From 0e9d4761a8912c443a675126e18d33b5d004c010 Mon Sep 17 00:00:00 2001 From: hyungjun <115551339+sukangpunch@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:54:47 +0900 Subject: [PATCH 13/31] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=84=9C?= =?UTF-8?q?=EA=B0=80=20APPROVED=20=EC=9D=B8=20=EC=9C=A0=EC=A0=80=EC=9D=98?= =?UTF-8?q?=20=EB=A9=98=ED=86=A0=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#562)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 지원서가 APPROVED 인 유저의 멘토 생성 기능 추가 * refactor: submitMentorApplication 메서드의 멘토 지원 유효 검증 부분을 메서드로 추출 * refactor: MentorMyPage 생성, 수정 부분의 channel 생성, 업데이트 부분 중복 제거 * test: Mentor 생성 관련 테스트 추가 * fix: 코드래빗 리뷰 적용 * refactor: 멘토 생성 시 channelRequest가 null 일 떄 예외 처리 * feat: MentorApplicationRequest 필드에 유효성 어노테이션 추가 * test: 채널 검색 시 siteUserId로 조회하는 문제 해결 * fix: 리뷰 수정사항 적용 * fix: 파일 끝에 개행 추가 * refactor: 멘토 생성 메서드에서 siteUser의 검증 제외 * refactor: dto 단에서 채널 리스트 null 검증 * feat: MentorApplication에 termId 추가 flyway 스크립트 추가 * fix: flyway 버전 충돌 해결 --- .../common/exception/ErrorCode.java | 2 + .../controller/MentorMyPageController.java | 12 ++ .../solidconnection/mentor/domain/Mentor.java | 21 +++ .../mentor/domain/MentorApplication.java | 8 +- .../mentor/dto/MentorApplicationRequest.java | 13 +- .../mentor/dto/MentorMyPageCreateRequest.java | 20 +++ .../MentorApplicationRepository.java | 3 + .../service/MentorApplicationService.java | 23 +++- .../mentor/service/MentorMyPageService.java | 46 ++++++- .../term/repository/TermRepository.java | 2 + ...V38__add_term_id_to_mentor_application.sql | 6 + src/main/resources/secret | 2 +- .../fixture/MentorApplicationFixture.java | 5 + .../MentorApplicationFixtureBuilder.java | 7 + .../service/MentorApplicationServiceTest.java | 10 +- .../service/MentorMyPageServiceTest.java | 126 +++++++++++++++++- 16 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageCreateRequest.java create mode 100644 src/main/resources/db/migration/V38__add_term_id_to_mentor_application.sql diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 17661fe2d..b8eea25eb 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -50,6 +50,7 @@ public enum ErrorCode { BLOCK_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "차단 대상 사용자를 찾을 수 없습니다."), TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학기입니다."), CURRENT_TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "현재 학기를 찾을 수 없습니다."), + MENTOR_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "멘토 지원서가 존재하지 않습니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -127,6 +128,7 @@ public enum ErrorCode { UNIVERSITY_ID_REQUIRED_FOR_CATALOG(HttpStatus.BAD_REQUEST.value(), "목록에서 학교를 선택한 경우 학교 정보가 필요합니다."), UNIVERSITY_ID_MUST_BE_NULL_FOR_OTHER(HttpStatus.BAD_REQUEST.value(), "기타 학교를 선택한 경우 학교 정보를 입력할 수 없습니다."), INVALID_UNIVERSITY_SELECT_TYPE(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 선택 방식입니다."), + MENTOR_ALREADY_EXISTS(HttpStatus.BAD_REQUEST.value(), "이미 존재하는 멘토입니다."), // socket UNAUTHORIZED_SUBSCRIBE(HttpStatus.FORBIDDEN.value(), "구독 권한이 없습니다."), diff --git a/src/main/java/com/example/solidconnection/mentor/controller/MentorMyPageController.java b/src/main/java/com/example/solidconnection/mentor/controller/MentorMyPageController.java index dd9289a3e..76c951a74 100644 --- a/src/main/java/com/example/solidconnection/mentor/controller/MentorMyPageController.java +++ b/src/main/java/com/example/solidconnection/mentor/controller/MentorMyPageController.java @@ -1,6 +1,7 @@ package com.example.solidconnection.mentor.controller; import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.mentor.dto.MentorMyPageCreateRequest; import com.example.solidconnection.mentor.dto.MentorMyPageResponse; import com.example.solidconnection.mentor.dto.MentorMyPageUpdateRequest; import com.example.solidconnection.mentor.service.MentorMyPageService; @@ -10,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +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; @@ -40,4 +42,14 @@ public ResponseEntity updateMentorMyPage( mentorMyPageService.updateMentorMyPage(siteUserId, mentorMyPageUpdateRequest); return ResponseEntity.ok().build(); } + + @RequireRoleAccess(roles = Role.MENTOR) + @PostMapping + public ResponseEntity createMentorMyPage( + @AuthorizedUser long siteUserId, + @Valid @RequestBody MentorMyPageCreateRequest request + ) { + mentorMyPageService.createMentorMyPage(siteUserId, request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/example/solidconnection/mentor/domain/Mentor.java b/src/main/java/com/example/solidconnection/mentor/domain/Mentor.java index 420170236..30dbfec20 100644 --- a/src/main/java/com/example/solidconnection/mentor/domain/Mentor.java +++ b/src/main/java/com/example/solidconnection/mentor/domain/Mentor.java @@ -53,6 +53,20 @@ public class Mentor extends BaseEntity { @OneToMany(mappedBy = "mentor", cascade = CascadeType.ALL, orphanRemoval = true) private List channels = new ArrayList<>(); + public Mentor( + String introduction, + String passTip, + long siteUserId, + Long universityId, + long termId + ) { + this.introduction = introduction; + this.passTip = passTip; + this.siteUserId = siteUserId; + this.universityId = universityId; + this.termId = termId; + } + public void increaseMenteeCount() { this.menteeCount++; } @@ -82,4 +96,11 @@ public void updateChannels(List channels) { } } } + + public void createChannels(List channels) { + for(Channel channel : channels) { + channel.updateMentor(this); + this.channels.add(channel); + } + } } diff --git a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java index 8f800dcff..29e1960c6 100644 --- a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java +++ b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java @@ -53,6 +53,9 @@ public class MentorApplication extends BaseEntity { @Column(nullable = false, name = "mentor_proof_url", length = 500) private String mentorProofUrl; + @Column(nullable = false, name = "term_id") + private long termId; + private String rejectedReason; @Column(nullable = false) @@ -61,7 +64,7 @@ public class MentorApplication extends BaseEntity { @Column(nullable = false) @Enumerated(EnumType.STRING) - private MentorApplicationStatus mentorApplicationStatus = MentorApplicationStatus.PENDING; + private MentorApplicationStatus mentorApplicationStatus; private static final Set ALLOWED = Collections.unmodifiableSet(EnumSet.of(ExchangeStatus.STUDYING_ABROAD, ExchangeStatus.AFTER_EXCHANGE)); @@ -72,6 +75,7 @@ public MentorApplication( Long universityId, UniversitySelectType universitySelectType, String mentorProofUrl, + long termId, ExchangeStatus exchangeStatus ) { validateExchangeStatus(exchangeStatus); @@ -82,7 +86,9 @@ public MentorApplication( this.universityId = universityId; this.universitySelectType = universitySelectType; this.mentorProofUrl = mentorProofUrl; + this.termId = termId; this.exchangeStatus = exchangeStatus; + this.mentorApplicationStatus = MentorApplicationStatus.PENDING; } private void validateUniversitySelection(UniversitySelectType universitySelectType, Long universityId) { diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentorApplicationRequest.java b/src/main/java/com/example/solidconnection/mentor/dto/MentorApplicationRequest.java index da6d206ab..c4c09977b 100644 --- a/src/main/java/com/example/solidconnection/mentor/dto/MentorApplicationRequest.java +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentorApplicationRequest.java @@ -3,12 +3,23 @@ import com.example.solidconnection.mentor.domain.UniversitySelectType; import com.example.solidconnection.siteuser.domain.ExchangeStatus; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record MentorApplicationRequest( + @NotNull(message = "교환 상태를 입력해주세요.") @JsonProperty("preparationStatus") ExchangeStatus exchangeStatus, + + @NotNull(message = "대학교 선택 유형을 입력해주세요.") UniversitySelectType universitySelectType, + + @NotNull(message = "국가를 입력해주세요") String country, - Long universityId + + Long universityId, + + @NotBlank(message = "학기를 입력해주세요.") + String term ) { } diff --git a/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageCreateRequest.java b/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageCreateRequest.java new file mode 100644 index 000000000..be0269b29 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/dto/MentorMyPageCreateRequest.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.mentor.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record MentorMyPageCreateRequest( + @NotBlank(message = "자기소개를 입력해주세요.") + String introduction, + + @NotBlank(message = "합격 레시피를 입력해주세요.") + String passTip, + + @NotNull + @Valid + List channels +) { + +} diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java index d03b53892..43b38ef4b 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java @@ -3,9 +3,12 @@ import com.example.solidconnection.mentor.domain.MentorApplication; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MentorApplicationRepository extends JpaRepository { boolean existsBySiteUserIdAndMentorApplicationStatusIn(long siteUserId, List mentorApplicationStatuses); + + Optional findBySiteUserIdAndMentorApplicationStatus(long siteUserId, MentorApplicationStatus mentorApplicationStatus); } diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java b/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java index d0716a156..e4e187808 100644 --- a/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java @@ -10,6 +10,8 @@ import com.example.solidconnection.s3.service.S3Service; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.term.domain.Term; +import com.example.solidconnection.term.repository.TermRepository; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,6 +20,7 @@ import org.springframework.web.multipart.MultipartFile; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_EXISTED; +import static com.example.solidconnection.common.exception.ErrorCode.TERM_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; @Service @@ -28,6 +31,7 @@ public class MentorApplicationService { private final MentorApplicationRepository mentorApplicationRepository; private final SiteUserRepository siteUserRepository; private final S3Service s3Service; + private final TermRepository termRepository; @Transactional public void submitMentorApplication( @@ -35,15 +39,12 @@ public void submitMentorApplication( MentorApplicationRequest mentorApplicationRequest, MultipartFile file ) { - if (mentorApplicationRepository.existsBySiteUserIdAndMentorApplicationStatusIn( - siteUserId, - List.of(MentorApplicationStatus.PENDING, MentorApplicationStatus.APPROVED)) - ) { - throw new CustomException(MENTOR_APPLICATION_ALREADY_EXISTED); - } + ensureNoPendingOrApprovedMentorApplication(siteUserId); SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + Term term = termRepository.findByName(mentorApplicationRequest.term()) + .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(file, ImgType.MENTOR_PROOF); MentorApplication mentorApplication = new MentorApplication( siteUser.getId(), @@ -51,8 +52,18 @@ public void submitMentorApplication( mentorApplicationRequest.universityId(), mentorApplicationRequest.universitySelectType(), uploadedFile.fileUrl(), + term.getId(), mentorApplicationRequest.exchangeStatus() ); mentorApplicationRepository.save(mentorApplication); } + + private void ensureNoPendingOrApprovedMentorApplication(long siteUserId) { + if (mentorApplicationRepository.existsBySiteUserIdAndMentorApplicationStatusIn( + siteUserId, + List.of(MentorApplicationStatus.PENDING, MentorApplicationStatus.APPROVED)) + ) { + throw new CustomException(MENTOR_APPLICATION_ALREADY_EXISTED); + } + } } diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java b/src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java index 810891b2b..7f226f380 100644 --- a/src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java +++ b/src/main/java/com/example/solidconnection/mentor/service/MentorMyPageService.java @@ -1,6 +1,8 @@ package com.example.solidconnection.mentor.service; import static com.example.solidconnection.common.exception.ErrorCode.CHANNEL_REGISTRATION_LIMIT_EXCEEDED; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_ALREADY_EXISTS; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.TERM_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; @@ -9,9 +11,13 @@ import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.mentor.domain.Channel; import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.MentorApplication; +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import com.example.solidconnection.mentor.dto.ChannelRequest; +import com.example.solidconnection.mentor.dto.MentorMyPageCreateRequest; import com.example.solidconnection.mentor.dto.MentorMyPageResponse; import com.example.solidconnection.mentor.dto.MentorMyPageUpdateRequest; +import com.example.solidconnection.mentor.repository.MentorApplicationRepository; import com.example.solidconnection.mentor.repository.MentorRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -36,6 +42,7 @@ public class MentorMyPageService { private final SiteUserRepository siteUserRepository; private final UniversityRepository universityRepository; private final TermRepository termRepository; + private final MentorApplicationRepository mentorApplicationRepository; @Transactional(readOnly = true) public MentorMyPageResponse getMentorMyPage(long siteUserId) { @@ -61,18 +68,53 @@ public void updateMentorMyPage(long siteUserId, MentorMyPageUpdateRequest reques updateChannel(request.channels(), mentor); } + private void updateChannel(List channelRequests, Mentor mentor) { + List newChannels = buildChannels(channelRequests); + mentor.updateChannels(newChannels); + } + + @Transactional + public void createMentorMyPage(long siteUserId, MentorMyPageCreateRequest request) { + validateUserCanCreateMentor(siteUserId); + validateChannelRegistrationLimit(request.channels()); + MentorApplication mentorApplication = mentorApplicationRepository.findBySiteUserIdAndMentorApplicationStatus(siteUserId, MentorApplicationStatus.APPROVED) + .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); + + Mentor mentor = new Mentor( + request.introduction(), + request.passTip(), + siteUserId, + mentorApplication.getUniversityId(), + mentorApplication.getTermId() + ); + + createChannels(request.channels(), mentor); + mentorRepository.save(mentor); + } + + private void validateUserCanCreateMentor(long siteUserId) { + if (mentorRepository.existsBySiteUserId(siteUserId)) { + throw new CustomException(MENTOR_ALREADY_EXISTS); + } + } + private void validateChannelRegistrationLimit(List channelRequests) { if (channelRequests.size() > CHANNEL_REGISTRATION_LIMIT) { throw new CustomException(CHANNEL_REGISTRATION_LIMIT_EXCEEDED); } } - private void updateChannel(List channelRequests, Mentor mentor) { + private void createChannels(List channelRequests, Mentor mentor) { + List newChannels = buildChannels(channelRequests); + mentor.createChannels(newChannels); + } + + private List buildChannels(List channelRequests) { int sequence = CHANNEL_SEQUENCE_START_NUMBER; List newChannels = new ArrayList<>(); for (ChannelRequest request : channelRequests) { newChannels.add(new Channel(sequence++, request.type(), request.url())); } - mentor.updateChannels(newChannels); + return newChannels; } } diff --git a/src/main/java/com/example/solidconnection/term/repository/TermRepository.java b/src/main/java/com/example/solidconnection/term/repository/TermRepository.java index 7badf27cb..763137dc9 100644 --- a/src/main/java/com/example/solidconnection/term/repository/TermRepository.java +++ b/src/main/java/com/example/solidconnection/term/repository/TermRepository.java @@ -7,4 +7,6 @@ public interface TermRepository extends JpaRepository { Optional findByIsCurrentTrue(); + + Optional findByName(String name); } diff --git a/src/main/resources/db/migration/V38__add_term_id_to_mentor_application.sql b/src/main/resources/db/migration/V38__add_term_id_to_mentor_application.sql new file mode 100644 index 000000000..87867f44e --- /dev/null +++ b/src/main/resources/db/migration/V38__add_term_id_to_mentor_application.sql @@ -0,0 +1,6 @@ +ALTER TABLE mentor_application + ADD COLUMN term_id BIGINT NOT NULL; + +ALTER TABLE mentor_application + ADD CONSTRAINT fk_mentor_application_term_id + FOREIGN KEY (term_id) REFERENCES term(id); \ No newline at end of file diff --git a/src/main/resources/secret b/src/main/resources/secret index 2f4a16822..8300cdeca 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 2f4a168223ea81cfe3447d6d95441b1a020fdbfe +Subproject commit 8300cdecaebfc28fd657064a00a44815a7bb2eee diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java index 918db7ef4..0baf62e2f 100644 --- a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java @@ -4,6 +4,7 @@ import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import com.example.solidconnection.mentor.domain.UniversitySelectType; import com.example.solidconnection.siteuser.domain.ExchangeStatus; +import com.example.solidconnection.term.fixture.TermFixture; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -12,6 +13,7 @@ public class MentorApplicationFixture { private final MentorApplicationFixtureBuilder mentorApplicationFixtureBuilder; + private final TermFixture termFixture; private static final String DEFAULT_COUNTRY_CODE = "US"; private static final String DEFAULT_PROOF_URL = "/mentor-proof.pdf"; @@ -28,6 +30,7 @@ public class MentorApplicationFixture { .universityId(universityId) .universitySelectType(selectType) .mentorProofUrl(DEFAULT_PROOF_URL) + .termId(termFixture.현재_학기("2025-1").getId()) .exchangeStatus(DEFAULT_EXCHANGE_STATUS) .create(); } @@ -43,6 +46,7 @@ public class MentorApplicationFixture { .universityId(universityId) .universitySelectType(selectType) .mentorProofUrl(DEFAULT_PROOF_URL) + .termId(termFixture.현재_학기("2025-1").getId()) .exchangeStatus(DEFAULT_EXCHANGE_STATUS) .mentorApplicationStatus(MentorApplicationStatus.APPROVED) .create(); @@ -59,6 +63,7 @@ public class MentorApplicationFixture { .universityId(universityId) .universitySelectType(selectType) .mentorProofUrl(DEFAULT_PROOF_URL) + .termId(termFixture.현재_학기("2025-1").getId()) .exchangeStatus(DEFAULT_EXCHANGE_STATUS) .mentorApplicationStatus(MentorApplicationStatus.REJECTED) .create(); diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java index fc6fe547d..fd2a74ff6 100644 --- a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java @@ -20,6 +20,7 @@ public class MentorApplicationFixtureBuilder { private Long universityId = null; private UniversitySelectType universitySelectType = UniversitySelectType.OTHER; private String mentorProofUrl = "/mentor-proof.pdf"; + private long termId; private ExchangeStatus exchangeStatus = ExchangeStatus.AFTER_EXCHANGE; private MentorApplicationStatus mentorApplicationStatus = MentorApplicationStatus.PENDING; @@ -52,6 +53,11 @@ public MentorApplicationFixtureBuilder mentorProofUrl(String mentorProofUrl) { return this; } + public MentorApplicationFixtureBuilder termId(long termId) { + this.termId = termId; + return this; + } + public MentorApplicationFixtureBuilder exchangeStatus(ExchangeStatus exchangeStatus) { this.exchangeStatus = exchangeStatus; return this; @@ -69,6 +75,7 @@ public MentorApplication create() { universityId, universitySelectType, mentorProofUrl, + termId, exchangeStatus ); ReflectionTestUtils.setField(mentorApplication, "mentorApplicationStatus", mentorApplicationStatus); diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java index aaf63b600..daa429fc3 100644 --- a/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java @@ -20,6 +20,8 @@ import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.term.domain.Term; +import com.example.solidconnection.term.fixture.TermFixture; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -41,6 +43,9 @@ public class MentorApplicationServiceTest { @Autowired private SiteUserFixture siteUserFixture; + @Autowired + private TermFixture termFixture; + @Autowired private MentorApplicationFixture mentorApplicationFixture; @@ -48,10 +53,12 @@ public class MentorApplicationServiceTest { private S3Service s3Service; private SiteUser user; + private Term term; @BeforeEach void setUp() { user = siteUserFixture.사용자(); + term = termFixture.현재_학기("2025-1"); } @Test @@ -190,7 +197,8 @@ private MentorApplicationRequest createMentorApplicationRequest(UniversitySelect ExchangeStatus.AFTER_EXCHANGE, universitySelectType, "US", - universityId + universityId, + term.getName() ); } } diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentorMyPageServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentorMyPageServiceTest.java index 7beb2e4f0..cecae13be 100644 --- a/src/test/java/com/example/solidconnection/mentor/service/MentorMyPageServiceTest.java +++ b/src/test/java/com/example/solidconnection/mentor/service/MentorMyPageServiceTest.java @@ -1,24 +1,35 @@ package com.example.solidconnection.mentor.service; +import static com.example.solidconnection.common.exception.ErrorCode.CHANNEL_REGISTRATION_LIMIT_EXCEEDED; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_ALREADY_EXISTS; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; import static com.example.solidconnection.mentor.domain.ChannelType.BLOG; +import static com.example.solidconnection.mentor.domain.ChannelType.BRUNCH; import static com.example.solidconnection.mentor.domain.ChannelType.INSTAGRAM; +import static com.example.solidconnection.mentor.domain.ChannelType.YOUTUBE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.tuple; import static org.junit.jupiter.api.Assertions.assertAll; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.mentor.domain.Channel; import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.UniversitySelectType; import com.example.solidconnection.mentor.dto.ChannelRequest; import com.example.solidconnection.mentor.dto.ChannelResponse; +import com.example.solidconnection.mentor.dto.MentorMyPageCreateRequest; import com.example.solidconnection.mentor.dto.MentorMyPageResponse; import com.example.solidconnection.mentor.dto.MentorMyPageUpdateRequest; import com.example.solidconnection.mentor.fixture.ChannelFixture; +import com.example.solidconnection.mentor.fixture.MentorApplicationFixture; import com.example.solidconnection.mentor.fixture.MentorFixture; import com.example.solidconnection.mentor.repository.ChannelRepositoryForTest; import com.example.solidconnection.mentor.repository.MentorRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.term.domain.Term; import com.example.solidconnection.term.fixture.TermFixture; import com.example.solidconnection.university.domain.University; import com.example.solidconnection.university.fixture.UniversityFixture; @@ -57,16 +68,22 @@ class MentorMyPageServiceTest { @Autowired private ChannelRepositoryForTest channelRepositoryForTest; + @Autowired + private MentorApplicationFixture mentorApplicationFixture; + private SiteUser mentorUser; private Mentor mentor; private University university; + private SiteUser siteUser; + private Term term; @BeforeEach void setUp() { - termFixture.현재_학기("2025-2"); + term = termFixture.현재_학기("2025-1"); university = universityFixture.메이지_대학(); mentorUser = siteUserFixture.멘토(1, "멘토"); mentor = mentorFixture.멘토(mentorUser.getId(), university.getId()); + siteUser = siteUserFixture.사용자(); } @Nested @@ -155,4 +172,111 @@ class 멘토의_마이_페이지를_수정한다 { ); } } + + @Nested + class 멘토의_마이페이지를_생성한다 { + + @Test + void 멘토_정보를_생성한다() { + // given + String introduction = "멘토 자기소개"; + String passTip = "멘토의 합격 팁"; + List channels = List.of( + new ChannelRequest(BLOG, "https://blog.com"), + new ChannelRequest(INSTAGRAM, "https://instagram.com"), + new ChannelRequest(YOUTUBE, "https://youtubr.com"), + new ChannelRequest(BRUNCH, "https://brunch.com") + ); + MentorMyPageCreateRequest request = new MentorMyPageCreateRequest(introduction, passTip, channels); + mentorApplicationFixture.승인된_멘토신청(siteUser.getId(), UniversitySelectType.CATALOG, university.getId()); + + // when + mentorMyPageService.createMentorMyPage(siteUser.getId(), request); + + // then + Mentor createMentor = mentorRepository.findBySiteUserId(siteUser.getId()).get(); + List createChannels = channelRepositoryForTest.findAllByMentorId(createMentor.getId()); + assertAll( + () -> assertThat(createMentor.getIntroduction()).isEqualTo(introduction), + () -> assertThat(createMentor.getPassTip()).isEqualTo(passTip), + () -> assertThat(createMentor.getTermId()).isEqualTo(term.getId()), + () -> assertThat(createMentor.getUniversityId()).isEqualTo(university.getId()), + () -> assertThat(createMentor.getSiteUserId()).isEqualTo(siteUser.getId()), + () -> assertThat(createMentor.getMenteeCount()).isEqualTo(0), + () -> assertThat(createMentor.isHasBadge()).isFalse(), + () -> assertThat(createChannels).extracting(Channel::getSequence, Channel::getType, Channel::getUrl) + .containsExactlyInAnyOrder( + tuple(1, BLOG, "https://blog.com"), + tuple(2, INSTAGRAM, "https://instagram.com"), + tuple(3, YOUTUBE, "https://youtubr.com"), + tuple(4, BRUNCH, "https://brunch.com") + ) + ); + } + + @Test + void 이미_멘토_정보가_존재하는데_생성_요청_시_예외가_발생한다() { + // given + MentorMyPageCreateRequest request = new MentorMyPageCreateRequest("introduction", "passTip", List.of()); + mentorFixture.멘토(siteUser.getId(), university.getId()); + + // when & then + assertThatCode(() -> mentorMyPageService.createMentorMyPage(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_ALREADY_EXISTS.getMessage()); + } + + @Test + void 채널을_제한_이상_생성하면_예외가_발생한다() { + // given + List newChannels = List.of( + new ChannelRequest(BLOG, "https://blog.com"), + new ChannelRequest(INSTAGRAM, "https://instagram.com"), + new ChannelRequest(YOUTUBE, "https://youtubr.com"), + new ChannelRequest(BRUNCH, "https://brunch.com"), + new ChannelRequest(BLOG, "https://blog.com") + ); + MentorMyPageCreateRequest request = new MentorMyPageCreateRequest("introduction", "passTip", newChannels); + + // when & then + assertThatCode(() -> mentorMyPageService.createMentorMyPage(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(CHANNEL_REGISTRATION_LIMIT_EXCEEDED.getMessage()); + } + + @Test + void 멘토_승격_요청_없이_멘토_정보_생성_시_예외가_발생한다() { + // given + MentorMyPageCreateRequest request = new MentorMyPageCreateRequest("introduction", "passTip", List.of()); + + // when & then + assertThatCode(() -> mentorMyPageService.createMentorMyPage(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage()); + } + + @Test + void 멘토_승격_요청_상태가_REJECTED_면_예외가_발생한다() { + // given + MentorMyPageCreateRequest request = new MentorMyPageCreateRequest("introduction", "passTip", List.of()); + mentorApplicationFixture.거절된_멘토신청(siteUser.getId(), UniversitySelectType.CATALOG, university.getId()); + + // when & then + assertThatCode(() -> mentorMyPageService.createMentorMyPage(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage()); + } + + @Test + void 멘토_승격_요청_상태가_PENDING_면_예외가_발생한다() { + // given + MentorMyPageCreateRequest request = new MentorMyPageCreateRequest("introduction", "passTip", List.of()); + mentorApplicationFixture.대기중_멘토신청(siteUser.getId(), UniversitySelectType.CATALOG, university.getId()); + + // when & then + assertThatCode(() -> mentorMyPageService.createMentorMyPage(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage()); + } + } } From d0523250d75e1a834753d75fc54a921ecb9b36c3 Mon Sep 17 00:00:00 2001 From: hyungjun <115551339+sukangpunch@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:23:41 +0900 Subject: [PATCH 14/31] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A9=98=ED=86=A0=20=EC=8A=B9=EA=B2=A9=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 어드민 멘토 지원서 페이징 조회 기능 추가 * feat: mentor/repository 패키지에 custom 패키지 추가 - custom 패키지에 페이징 조회를 책임지는 MentorApplicationFilterRepository 추가 - MentorApplicationSearchCondition 에서 넘긴 keyword 기반으로 닉네임, 권역, 나라, 학교명으로 필터링 검색 기능 추가 - MentorApplicationSearchCondition 에서 넘긴 mentorApplicationStatus 기반으로 승인, 거절, 진행중 으로 필터링 기능 추가 * test: 어드민 멘토 지원서 페이징 조회 테스트 추가 * feat: MentorApplication 엔티티에 approved_at 필드 추가 flyway 스크립트 작성 * fix: 파일 끝에 개행 추가 * refactor: 페이징 조회 시 count 쿼리에 불필요한 조인 막기 * fix: 코드래빗 리뷰 적용 * fix: flyway V39 스크립트 파일명 수정 * test: 테스트 코드 오류 수정, 검증 추가   * test: 기대하는 값이랑 다른 테스트 응답을 수정합니다 --- .../AdminMentorApplicationController.java | 37 +++ .../admin/dto/MentorApplicationResponse.java | 18 ++ .../dto/MentorApplicationSearchCondition.java | 12 + .../dto/MentorApplicationSearchResponse.java | 8 + .../AdminMentorApplicationService.java | 25 +++ .../mentor/domain/MentorApplication.java | 4 + .../MentorApplicationRepository.java | 3 +- .../MentorApplicationFilterRepository.java | 12 + ...MentorApplicationFilterRepositoryImpl.java | 145 ++++++++++++ ...39__add_approved_at_mentor_application.sql | 7 + .../AdminMentorApplicationServiceTest.java | 212 ++++++++++++++++++ 11 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java create mode 100644 src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java create mode 100644 src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java create mode 100644 src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepository.java create mode 100644 src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java create mode 100644 src/main/resources/db/migration/V39__add_approved_at_mentor_application.sql create mode 100644 src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java new file mode 100644 index 000000000..02d093998 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.admin.controller; + +import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; +import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; +import com.example.solidconnection.admin.service.AdminMentorApplicationService; +import com.example.solidconnection.common.response.PageResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/admin/mentor-applications") +@RestController +@Slf4j +public class AdminMentorApplicationController { + private final AdminMentorApplicationService adminMentorApplicationService; + + @GetMapping + public ResponseEntity> searchMentorApplications( + @Valid @ModelAttribute MentorApplicationSearchCondition mentorApplicationSearchCondition, + Pageable pageable + ) { + Page page = adminMentorApplicationService.searchMentorApplications( + mentorApplicationSearchCondition, + pageable + ); + + return ResponseEntity.ok(PageResponse.of(page)); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java new file mode 100644 index 000000000..1fafc2df4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import java.time.ZonedDateTime; + +public record MentorApplicationResponse( + long id, + String region, + String country, + String university, + String mentorProofUrl, + MentorApplicationStatus mentorApplicationStatus, + String rejectedReason, + ZonedDateTime createdAt, + ZonedDateTime approvedAt +) { + +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java new file mode 100644 index 000000000..9871ba6d6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import java.time.LocalDate; + +public record MentorApplicationSearchCondition( + MentorApplicationStatus mentorApplicationStatus, + String keyword, + LocalDate createdAt +) { + +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchResponse.java new file mode 100644 index 000000000..247e289d5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchResponse.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.admin.dto; + +public record MentorApplicationSearchResponse( + SiteUserResponse siteUserResponse, + MentorApplicationResponse mentorApplicationResponse +) { + +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java new file mode 100644 index 000000000..cf7504dea --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.admin.service; + +import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; +import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; +import com.example.solidconnection.mentor.repository.MentorApplicationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class AdminMentorApplicationService { + + private final MentorApplicationRepository mentorApplicationRepository; + + @Transactional(readOnly = true) + public Page searchMentorApplications( + MentorApplicationSearchCondition mentorApplicationSearchCondition, + Pageable pageable + ) { + return mentorApplicationRepository.searchMentorApplications(mentorApplicationSearchCondition, pageable); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java index 29e1960c6..c6eda9ad7 100644 --- a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java +++ b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java @@ -11,6 +11,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import java.time.ZonedDateTime; import java.util.Collections; import java.util.EnumSet; import java.util.Set; @@ -66,6 +67,9 @@ public class MentorApplication extends BaseEntity { @Enumerated(EnumType.STRING) private MentorApplicationStatus mentorApplicationStatus; + @Column + private ZonedDateTime approvedAt; + private static final Set ALLOWED = Collections.unmodifiableSet(EnumSet.of(ExchangeStatus.STUDYING_ABROAD, ExchangeStatus.AFTER_EXCHANGE)); diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java index 43b38ef4b..f22c1cac4 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java @@ -2,11 +2,12 @@ import com.example.solidconnection.mentor.domain.MentorApplication; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import com.example.solidconnection.mentor.repository.custom.MentorApplicationFilterRepository; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface MentorApplicationRepository extends JpaRepository { +public interface MentorApplicationRepository extends JpaRepository , MentorApplicationFilterRepository { boolean existsBySiteUserIdAndMentorApplicationStatusIn(long siteUserId, List mentorApplicationStatuses); diff --git a/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepository.java new file mode 100644 index 000000000..1490aa392 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepository.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.mentor.repository.custom; + +import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; +import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface MentorApplicationFilterRepository { + + Page searchMentorApplications(MentorApplicationSearchCondition mentorApplicationSearchCondition, Pageable pageable); + +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java new file mode 100644 index 000000000..788bda8a5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java @@ -0,0 +1,145 @@ +package com.example.solidconnection.mentor.repository.custom; + +import static com.example.solidconnection.location.country.domain.QCountry.country; +import static com.example.solidconnection.location.region.domain.QRegion.region; +import static com.example.solidconnection.mentor.domain.QMentorApplication.mentorApplication; +import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser; +import static com.example.solidconnection.university.domain.QUniversity.university; +import static org.springframework.util.StringUtils.hasText; + +import com.example.solidconnection.admin.dto.MentorApplicationResponse; +import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; +import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; +import com.example.solidconnection.admin.dto.SiteUserResponse; +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import com.querydsl.core.types.ConstructorExpression; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +public class MentorApplicationFilterRepositoryImpl implements MentorApplicationFilterRepository { + + private static final ZoneId SYSTEM_ZONE_ID = ZoneId.systemDefault(); + + private static final ConstructorExpression SITE_USER_RESPONSE_PROJECTION = + Projections.constructor( + SiteUserResponse.class, + siteUser.id, + siteUser.nickname, + siteUser.profileImageUrl + ); + + private static final ConstructorExpression MENTOR_APPLICATION_RESPONSE_PROJECTION = + Projections.constructor( + MentorApplicationResponse.class, + mentorApplication.id, + region.koreanName, + country.koreanName, + university.koreanName, + mentorApplication.mentorProofUrl, + mentorApplication.mentorApplicationStatus, + mentorApplication.rejectedReason, + mentorApplication.createdAt, + mentorApplication.approvedAt + ); + + private static final ConstructorExpression MENTOR_APPLICATION_SEARCH_RESPONSE_PROJECTION = + Projections.constructor( + MentorApplicationSearchResponse.class, + SITE_USER_RESPONSE_PROJECTION, + MENTOR_APPLICATION_RESPONSE_PROJECTION + ); + + private final JPAQueryFactory queryFactory; + + @Autowired + public MentorApplicationFilterRepositoryImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public Page searchMentorApplications(MentorApplicationSearchCondition condition, Pageable pageable) { + List content = queryFactory + .select(MENTOR_APPLICATION_SEARCH_RESPONSE_PROJECTION) + .from(mentorApplication) + .join(siteUser).on(mentorApplication.siteUserId.eq(siteUser.id)) + .leftJoin(university).on(mentorApplication.universityId.eq(university.id)) + .leftJoin(region).on(university.region.eq(region)) + .leftJoin(country).on(university.country.eq(country)) + .where( + verifyMentorStatusEq(condition.mentorApplicationStatus()), + keywordContains(condition.keyword()), + createdAtEq(condition.createdAt()) + ) + .orderBy(mentorApplication.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = createCountQuery(condition).fetchOne(); + + return new PageImpl<>(content, pageable, totalCount != null ? totalCount : 0L); + } + + private JPAQuery createCountQuery(MentorApplicationSearchCondition condition) { + JPAQuery query = queryFactory + .select(mentorApplication.count()) + .from(mentorApplication); + + String keyword = condition.keyword(); + + if (hasText(keyword)) { + query.join(siteUser).on(mentorApplication.siteUserId.eq(siteUser.id)) + .leftJoin(university).on(mentorApplication.universityId.eq(university.id)) + .leftJoin(region).on(university.region.eq(region)) + .leftJoin(country).on(university.country.eq(country)); + } + + return query.where( + verifyMentorStatusEq(condition.mentorApplicationStatus()), + keywordContains(condition.keyword()), + createdAtEq(condition.createdAt()) + ); + } + + private BooleanExpression verifyMentorStatusEq(MentorApplicationStatus status) { + return status != null ? mentorApplication.mentorApplicationStatus.eq(status) : null; + } + + private BooleanExpression keywordContains(String keyword) { + if (!hasText(keyword)) { + return null; + } + + return siteUser.nickname.containsIgnoreCase(keyword) + .or(university.koreanName.containsIgnoreCase(keyword)) + .or(region.koreanName.containsIgnoreCase(keyword)) + .or(country.koreanName.containsIgnoreCase(keyword)); + } + + private BooleanExpression createdAtEq(LocalDate createdAt) { + if (createdAt == null) { + return null; + } + + LocalDateTime startOfDay = createdAt.atStartOfDay(); + LocalDateTime endOfDay = createdAt.plusDays(1).atStartOfDay().minusNanos(1); + + return mentorApplication.createdAt.between( + startOfDay.atZone(SYSTEM_ZONE_ID), + endOfDay.atZone(SYSTEM_ZONE_ID) + ); + } +} diff --git a/src/main/resources/db/migration/V39__add_approved_at_mentor_application.sql b/src/main/resources/db/migration/V39__add_approved_at_mentor_application.sql new file mode 100644 index 000000000..d7319b4aa --- /dev/null +++ b/src/main/resources/db/migration/V39__add_approved_at_mentor_application.sql @@ -0,0 +1,7 @@ +ALTER TABLE mentor_application + ADD COLUMN approved_at DATETIME(6); + +UPDATE mentor_application +SET approved_at = NOW() +WHERE mentor_application_status = 'APPROVED' + AND approved_at IS NULL; \ No newline at end of file diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java new file mode 100644 index 000000000..b41a9008b --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java @@ -0,0 +1,212 @@ +package com.example.solidconnection.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; +import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; +import com.example.solidconnection.mentor.domain.MentorApplication; +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import com.example.solidconnection.mentor.domain.UniversitySelectType; +import com.example.solidconnection.mentor.fixture.MentorApplicationFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.fixture.UniversityFixture; +import java.time.LocalDate; +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.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@TestContainerSpringBootTest +@DisplayName("멘토 지원서 관리자 서비스 테스트") +class AdminMentorApplicationServiceTest { + + @Autowired + private AdminMentorApplicationService adminMentorApplicationService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private MentorApplicationFixture mentorApplicationFixture; + + @Autowired + private UniversityFixture universityFixture; + + private MentorApplication mentorApplication1; + private MentorApplication mentorApplication2; + private MentorApplication mentorApplication3; + private MentorApplication mentorApplication4; + private MentorApplication mentorApplication5; + private MentorApplication mentorApplication6; + + @BeforeEach + void setUp() { + SiteUser user1 = siteUserFixture.사용자(1, "test1"); + SiteUser user2 = siteUserFixture.사용자(2, "test2"); + SiteUser user3 = siteUserFixture.사용자(3, "test3"); + SiteUser user4 = siteUserFixture.사용자(4, "test4"); + SiteUser user5 = siteUserFixture.사용자(5, "test5"); + SiteUser user6 = siteUserFixture.사용자(6, "test6"); + University university1 = universityFixture.메이지_대학(); + University university2 = universityFixture.괌_대학(); + University university3 = universityFixture.그라츠_대학(); + mentorApplication1 = mentorApplicationFixture.승인된_멘토신청(user1.getId(), UniversitySelectType.CATALOG, university1.getId()); + mentorApplication2 = mentorApplicationFixture.대기중_멘토신청(user2.getId(), UniversitySelectType.CATALOG, university2.getId()); + mentorApplication3 = mentorApplicationFixture.거절된_멘토신청(user3.getId(), UniversitySelectType.CATALOG, university3.getId()); + mentorApplication4 = mentorApplicationFixture.승인된_멘토신청(user4.getId(), UniversitySelectType.CATALOG, university3.getId()); + mentorApplication5 = mentorApplicationFixture.대기중_멘토신청(user5.getId(), UniversitySelectType.CATALOG, university1.getId()); + mentorApplication6 = mentorApplicationFixture.거절된_멘토신청(user6.getId(), UniversitySelectType.CATALOG, university2.getId()); + } + + @Nested + class 멘토_승격_지원서_목록_조회 { + + @Test + void 멘토_승격_상태를_조건으로_페이징하여_조회한다() { + // given + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(MentorApplicationStatus.PENDING,null, null); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of(mentorApplication2, mentorApplication5); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().mentorApplicationStatus()) + .containsOnly(MentorApplicationStatus.PENDING); + } + + @Test + void 닉네임_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ + // given + String nickname = "test1"; + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, nickname, null); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of(mentorApplication1); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)); + assertThat(response.getContent()) + .extracting(content -> content.siteUserResponse().nickname()) + .containsOnly(nickname); + } + + @Test + void 대학명_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ + // given + String universityKoreanName = "메이지 대학"; + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, universityKoreanName, null); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of(mentorApplication1, mentorApplication5); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().university()) + .containsOnly(universityKoreanName); + } + + @Test + void 지역명_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ + // given + String regionKoreanName = "유럽"; + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, regionKoreanName, null); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of(mentorApplication3, mentorApplication4); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().region()) + .containsOnly(regionKoreanName); + } + + @Test + void 나라명_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ + // given + String countryKoreanName = "오스트리아"; + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, countryKoreanName, null); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of(mentorApplication3, mentorApplication4); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().country()) + .containsOnly(countryKoreanName); + } + + @Test + void 모든_조건으로_페이징하여_조회한다() { + // given + String regionKoreanName = "영미권"; + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(MentorApplicationStatus.PENDING, regionKoreanName, LocalDate.now()); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of(mentorApplication2); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().mentorApplicationStatus()) + .containsOnly(MentorApplicationStatus.PENDING); + assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().region()) + .containsOnly(regionKoreanName); + } + } +} From 3c342a8deeb872e5f823e076e231e85a522592df Mon Sep 17 00:00:00 2001 From: hyungjun <115551339+sukangpunch@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:47:42 +0900 Subject: [PATCH 15/31] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A9=98=ED=86=A0=20=EC=8A=B9=EA=B2=A9=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=84=9C=20=EC=8A=B9=EC=9D=B8/=EA=B1=B0=EC=A0=88=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5,=20=EC=83=81=ED=83=9C=20=EB=B3=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20=EA=B0=9C=EC=88=98=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(#577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 어드민 멘토 승격 지원서 승인/거절 기능 추가 * test: 어드민 멘토 지원서 승인/거절 테스트 추가 * feat: 멘토 지원서 상태별 개수 조회 기능 추가 * test: 멘토 지원서 상태별 개수 조회 테스트 추가 * fix: 대학이 선택되지 않은 멘토 지원서 승인 시 예외 발생하도록 수정 * refactor: 리뷰 내용 반영 * refactor: MENTOR_APPLICATION_ALREADY_CONFIRM -> MENTOR_APPLICATION_ALREADY_CONFIRMED 로 수정 * refactor: 멘토 지원서 거절 사유 관련하여 기획에 명시되지 않은 길이 제한 제거 * refactor: 리뷰 적용 * refactor: 변수명, 필드명 일관성 맞추기 * test: assertAll 적용 --- .../AdminMentorApplicationController.java | 28 +++ .../dto/MentorApplicationCountResponse.java | 9 + .../dto/MentorApplicationRejectRequest.java | 10 ++ .../AdminMentorApplicationService.java | 41 +++++ .../common/exception/ErrorCode.java | 2 + .../mentor/domain/MentorApplication.java | 20 +++ .../MentorApplicationRepository.java | 2 + .../AdminMentorApplicationServiceTest.java | 168 ++++++++++++++++++ 8 files changed, 280 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/dto/MentorApplicationCountResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/dto/MentorApplicationRejectRequest.java diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java index 02d093998..1c64df145 100644 --- a/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.controller; +import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; +import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; import com.example.solidconnection.admin.service.AdminMentorApplicationService; @@ -12,6 +14,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +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 org.springframework.web.bind.annotation.RestController; @@ -34,4 +39,27 @@ public ResponseEntity> searchMento return ResponseEntity.ok(PageResponse.of(page)); } + + @PostMapping("/{mentorApplicationId}/approve") + public ResponseEntity approveMentorApplication( + @PathVariable("mentorApplicationId") Long mentorApplicationId + ) { + adminMentorApplicationService.approveMentorApplication(mentorApplicationId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{mentorApplicationId}/reject") + public ResponseEntity rejectMentorApplication( + @PathVariable("mentorApplicationId") Long mentorApplicationId, + @Valid @RequestBody MentorApplicationRejectRequest request + ) { + adminMentorApplicationService.rejectMentorApplication(mentorApplicationId, request); + return ResponseEntity.ok().build(); + } + + @GetMapping("/count") + public ResponseEntity getMentorApplicationCount() { + MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount(); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationCountResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationCountResponse.java new file mode 100644 index 000000000..e65ef4cb5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationCountResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.admin.dto; + +public record MentorApplicationCountResponse( + long approvedCount, + long pendingCount, + long rejectedCount +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationRejectRequest.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationRejectRequest.java new file mode 100644 index 000000000..0ad507880 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationRejectRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MentorApplicationRejectRequest( + @NotBlank(message = "거절 사유는 필수입니다") + String rejectedReason +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java index cf7504dea..2c8ab4949 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -1,7 +1,15 @@ package com.example.solidconnection.admin.service; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED; + +import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; +import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.mentor.domain.MentorApplication; +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import com.example.solidconnection.mentor.repository.MentorApplicationRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -22,4 +30,37 @@ public Page searchMentorApplications( ) { return mentorApplicationRepository.searchMentorApplications(mentorApplicationSearchCondition, pageable); } + + @Transactional + public void approveMentorApplication(Long mentorApplicationId) { + MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId) + .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); + + if(mentorApplication.getUniversityId() == null){ + throw new CustomException(MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED); + } + + mentorApplication.approve(); + } + + @Transactional + public void rejectMentorApplication(long mentorApplicationId, MentorApplicationRejectRequest request) { + MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId) + .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); + + mentorApplication.reject(request.rejectedReason()); + } + + @Transactional(readOnly = true) + public MentorApplicationCountResponse getMentorApplicationCount() { + long approvedCount = mentorApplicationRepository.countByMentorApplicationStatus(MentorApplicationStatus.APPROVED); + long pendingCount = mentorApplicationRepository.countByMentorApplicationStatus(MentorApplicationStatus.PENDING); + long rejectedCount = mentorApplicationRepository.countByMentorApplicationStatus(MentorApplicationStatus.REJECTED); + + return new MentorApplicationCountResponse( + approvedCount, + pendingCount, + rejectedCount + ); + } } diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index b8eea25eb..a1837d303 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -129,6 +129,8 @@ public enum ErrorCode { UNIVERSITY_ID_MUST_BE_NULL_FOR_OTHER(HttpStatus.BAD_REQUEST.value(), "기타 학교를 선택한 경우 학교 정보를 입력할 수 없습니다."), INVALID_UNIVERSITY_SELECT_TYPE(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 선택 방식입니다."), MENTOR_ALREADY_EXISTS(HttpStatus.BAD_REQUEST.value(), "이미 존재하는 멘토입니다."), + MENTOR_APPLICATION_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토 승격 요청 입니다."), + MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED(HttpStatus.BAD_REQUEST.value(), "승인하려는 멘토 신청에 대학교가 선택되지 않았습니다."), // socket UNAUTHORIZED_SUBSCRIBE(HttpStatus.FORBIDDEN.value(), "구독 권한이 없습니다."), diff --git a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java index c6eda9ad7..502ae1237 100644 --- a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java +++ b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java @@ -1,5 +1,9 @@ package com.example.solidconnection.mentor.domain; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED; +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.MICROS; + import com.example.solidconnection.common.BaseEntity; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; @@ -116,4 +120,20 @@ private void validateExchangeStatus(ExchangeStatus exchangeStatus) { throw new CustomException(ErrorCode.INVALID_EXCHANGE_STATUS_FOR_MENTOR); } } + + public void approve(){ + if(this.mentorApplicationStatus != MentorApplicationStatus.PENDING) { + throw new CustomException(MENTOR_APPLICATION_ALREADY_CONFIRMED); + } + this.mentorApplicationStatus = MentorApplicationStatus.APPROVED; + this.approvedAt = ZonedDateTime.now(UTC).truncatedTo(MICROS); + } + + public void reject(String rejectedReason){ + if(this.mentorApplicationStatus != MentorApplicationStatus.PENDING) { + throw new CustomException(MENTOR_APPLICATION_ALREADY_CONFIRMED); + } + this.mentorApplicationStatus = MentorApplicationStatus.REJECTED; + this.rejectedReason = rejectedReason; + } } diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java index f22c1cac4..144b5461c 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java @@ -12,4 +12,6 @@ public interface MentorApplicationRepository extends JpaRepository mentorApplicationStatuses); Optional findBySiteUserIdAndMentorApplicationStatus(long siteUserId, MentorApplicationStatus mentorApplicationStatus); + + long countByMentorApplicationStatus(MentorApplicationStatus mentorApplicationStatus); } diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java index b41a9008b..dd844d866 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java @@ -1,13 +1,22 @@ package com.example.solidconnection.admin.service; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; +import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.mentor.domain.MentorApplication; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import com.example.solidconnection.mentor.domain.UniversitySelectType; import com.example.solidconnection.mentor.fixture.MentorApplicationFixture; +import com.example.solidconnection.mentor.repository.MentorApplicationRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; @@ -40,6 +49,9 @@ class AdminMentorApplicationServiceTest { @Autowired private UniversityFixture universityFixture; + @Autowired + private MentorApplicationRepository mentorApplicationRepository; + private MentorApplication mentorApplication1; private MentorApplication mentorApplication2; private MentorApplication mentorApplication3; @@ -209,4 +221,160 @@ class 멘토_승격_지원서_목록_조회 { .containsOnly(regionKoreanName); } } + + @Nested + class 멘토_승격_지원서_승인{ + + @Test + void 대기중인_멘토_지원서를_승인한다() { + // given + long pendingMentorApplicationId = mentorApplication2.getId(); + + // when + adminMentorApplicationService.approveMentorApplication(pendingMentorApplicationId); + + // then + MentorApplication result = mentorApplicationRepository.findById(mentorApplication2.getId()).get(); + assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.APPROVED); + assertThat(result.getApprovedAt()).isNotNull(); + } + + @Test + void 대학이_선택되지_않은_멘토_지원서를_승인하면_예외가_발생한다(){ + // given + SiteUser user = siteUserFixture.사용자(); + MentorApplication noUniversityIdMentorApplication = mentorApplicationFixture.대기중_멘토신청(user.getId(), UniversitySelectType.OTHER, null); + + // when & then + assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(noUniversityIdMentorApplication.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED.getMessage()); + } + + @Test + void 이미_승인된_멘토_지원서를_승인하면_예외가_발생한다() { + // given + long approvedMentorApplicationId = mentorApplication1.getId(); + + // when & then + assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(approvedMentorApplicationId)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage()); + } + + @Test + void 이미_거절된_멘토_지원서를_승인하면_예외가_발생한다() { + // given + long rejectedMentorApplicationId = mentorApplication3.getId(); + + // when & then + assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(rejectedMentorApplicationId)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage()); + } + + @Test + void 존재하지_않는_멘토_지원서를_승인하면_예외가_발생한다() { + // given + long nonExistentId = 99999L; + + // when & then + assertThatCode(() -> adminMentorApplicationService.approveMentorApplication(nonExistentId)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage()); + } + } + + @Nested + class 멘토_승격_지원서_거절{ + + @Test + void 대기중인_멘토_지원서를_거절한다() { + // given + long pendingMentorApplicationId = mentorApplication2.getId(); + MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락"); + + // when + adminMentorApplicationService.rejectMentorApplication(pendingMentorApplicationId, request); + + // then + MentorApplication result = mentorApplicationRepository.findById(mentorApplication2.getId()).get(); + assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.REJECTED); + assertThat(result.getRejectedReason()).isEqualTo(request.rejectedReason()); + } + + @Test + void 이미_승인된_멘토_지원서를_거절하면_예외가_발생한다() { + // given + long approvedMentorApplicationId = mentorApplication1.getId(); + MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락"); + + // when & then + assertThatCode(() -> adminMentorApplicationService.rejectMentorApplication(approvedMentorApplicationId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage()); + } + + @Test + void 이미_거절된_멘토_지원서를_거절하면_예외가_발생한다() { + // given + long rejectedMentorApplicationId = mentorApplication3.getId(); + MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락"); + + // when & then + assertThatCode(() -> adminMentorApplicationService.rejectMentorApplication(rejectedMentorApplicationId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_ALREADY_CONFIRMED.getMessage()); + } + + @Test + void 존재하지_않는_멘토_지원서를_거절하면_예외가_발생한다() { + // given + long nonExistentId = 99999L; + MentorApplicationRejectRequest request = new MentorApplicationRejectRequest("파견학교 인증 자료 누락"); + + // when & then + assertThatCode(() -> adminMentorApplicationService.rejectMentorApplication(nonExistentId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage()); + } + } + + @Nested + class 멘토_지원서_상태별_개수_조회 { + + @Test + void 상태별_멘토_지원서_개수를_조회한다() { + // given + List expectedApprovedCount = List.of(mentorApplication1, mentorApplication4); + List expectedPendingCount = List.of(mentorApplication2, mentorApplication5); + List expectedRejectedCount = List.of(mentorApplication3, mentorApplication6); + + // when + MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount(); + + // then + assertAll( + () -> assertThat(response.approvedCount()).isEqualTo(expectedApprovedCount.size()), + () -> assertThat(response.pendingCount()).isEqualTo(expectedPendingCount.size()), + () -> assertThat(response.rejectedCount()).isEqualTo(expectedRejectedCount.size()) + ); + } + + @Test + void 멘토_지원서가_없으면_모든_개수가_0이다() { + // given + mentorApplicationRepository.deleteAll(); + + // when + MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount(); + + // then + assertAll( + () -> assertThat(response.approvedCount()).isEqualTo(0L), + () -> assertThat(response.pendingCount()).isEqualTo(0L), + () -> assertThat(response.rejectedCount()).isEqualTo(0L) + ); + } + } } From e673bb2afbba8be0ca62d533073e04b5c084b1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EA=B7=9C=ED=98=81?= <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sun, 21 Dec 2025 16:47:03 +0900 Subject: [PATCH 16/31] =?UTF-8?q?feat:=20region=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 지역 생성 기능 구현 (AdminRegion) 지역을 생성하는 기능을 구현했습니다: - AdminRegionCreateRequest: 지역 생성 요청 DTO - AdminRegionResponse: 지역 응답 DTO - AdminRegionService.createRegion(): 중복 검사를 포함한 지역 생성 로직 - AdminRegionController.createRegion(): HTTP POST 엔드포인트 중복 검사: - 지역 코드 중복 확인 - 한글명 중복 확인 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat: 지역 수정/삭제/조회 기능 구현 및 테스트 추가 (AdminRegion) 지역 관리 기능을 완성했습니다: 구현 기능: - AdminRegionUpdateRequest: 지역 수정 요청 DTO - AdminRegionService.updateRegion(): 한글명 중복 검사를 포함한 지역 수정 - AdminRegionService.deleteRegion(): 지역 삭제 - AdminRegionService.getAllRegions(): 전체 지역 조회 - AdminRegionController: 수정/삭제/조회 HTTP 엔드포인트 테스트 코드 (AdminRegionServiceTest): - CREATE: 정상 생성, 코드 중복, 한글명 중복 테스트 - UPDATE: 정상 수정, NOT_FOUND, 중복 한글명, 동일 한글명 테스트 - DELETE: 정상 삭제, NOT_FOUND 테스트 - READ: 빈 목록, 전체 조회 테스트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: 지역 관리 관련 에러코드 추가 (ErrorCode) 지역 관리 기능에 필요한 에러코드를 추가했습니다: - REGION_NOT_FOUND: 존재하지 않는 지역 - REGION_ALREADY_EXISTS: 이미 존재하는 지역 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: jpa 오류 수정 * refactor: 코드리뷰 반영 --------- Co-authored-by: Claude --- .../controller/AdminRegionController.java | 58 ++++++ .../region/dto/AdminRegionCreateRequest.java | 16 ++ .../region/dto/AdminRegionResponse.java | 16 ++ .../region/dto/AdminRegionUpdateRequest.java | 12 ++ .../region/service/AdminRegionService.java | 81 ++++++++ .../common/exception/ErrorCode.java | 2 + .../location/region/domain/Region.java | 4 + .../region/repository/RegionRepository.java | 2 +- .../service/AdminRegionServiceTest.java | 196 ++++++++++++++++++ 9 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/solidconnection/admin/location/region/controller/AdminRegionController.java create mode 100644 src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionCreateRequest.java create mode 100644 src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionUpdateRequest.java create mode 100644 src/main/java/com/example/solidconnection/admin/location/region/service/AdminRegionService.java create mode 100644 src/test/java/com/example/solidconnection/admin/location/region/service/AdminRegionServiceTest.java diff --git a/src/main/java/com/example/solidconnection/admin/location/region/controller/AdminRegionController.java b/src/main/java/com/example/solidconnection/admin/location/region/controller/AdminRegionController.java new file mode 100644 index 000000000..354bf6b21 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/location/region/controller/AdminRegionController.java @@ -0,0 +1,58 @@ +package com.example.solidconnection.admin.location.region.controller; + +import com.example.solidconnection.admin.location.region.dto.AdminRegionCreateRequest; +import com.example.solidconnection.admin.location.region.dto.AdminRegionResponse; +import com.example.solidconnection.admin.location.region.dto.AdminRegionUpdateRequest; +import com.example.solidconnection.admin.location.region.service.AdminRegionService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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 org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/admin/regions") +@RestController +public class AdminRegionController { + + private final AdminRegionService adminRegionService; + + @GetMapping + public ResponseEntity> getAllRegions() { + List responses = adminRegionService.getAllRegions(); + return ResponseEntity.ok(responses); + } + + @PostMapping + public ResponseEntity createRegion( + @Valid @RequestBody AdminRegionCreateRequest request + ) { + AdminRegionResponse response = adminRegionService.createRegion(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PutMapping("/{code}") + public ResponseEntity updateRegion( + @PathVariable String code, + @Valid @RequestBody AdminRegionUpdateRequest request + ) { + AdminRegionResponse response = adminRegionService.updateRegion(code, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{code}") + public ResponseEntity deleteRegion( + @PathVariable String code + ) { + adminRegionService.deleteRegion(code); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionCreateRequest.java b/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionCreateRequest.java new file mode 100644 index 000000000..03d60966d --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionCreateRequest.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.admin.location.region.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminRegionCreateRequest( + @NotBlank(message = "지역 코드는 필수입니다") + @Size(min = 1, max = 10, message = "지역 코드는 1자 이상 10자 이하여야 합니다") + String code, + + @NotBlank(message = "한글 지역명은 필수입니다") + @Size(min = 1, max = 100, message = "한글 지역명은 1자 이상 100자 이하여야 합니다") + String koreanName +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionResponse.java b/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionResponse.java new file mode 100644 index 000000000..33beeaf68 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionResponse.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.admin.location.region.dto; + +import com.example.solidconnection.location.region.domain.Region; + +public record AdminRegionResponse( + String code, + String koreanName +) { + + public static AdminRegionResponse from(Region region) { + return new AdminRegionResponse( + region.getCode(), + region.getKoreanName() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionUpdateRequest.java b/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionUpdateRequest.java new file mode 100644 index 000000000..b61a41ba3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/location/region/dto/AdminRegionUpdateRequest.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.location.region.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminRegionUpdateRequest( + @NotBlank(message = "한글 지역명은 필수입니다") + @Size(min = 1, max = 100, message = "한글 지역명은 1자 이상 100자 이하여야 합니다") + String koreanName +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/location/region/service/AdminRegionService.java b/src/main/java/com/example/solidconnection/admin/location/region/service/AdminRegionService.java new file mode 100644 index 000000000..eed35efae --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/location/region/service/AdminRegionService.java @@ -0,0 +1,81 @@ +package com.example.solidconnection.admin.location.region.service; + +import com.example.solidconnection.admin.location.region.dto.AdminRegionCreateRequest; +import com.example.solidconnection.admin.location.region.dto.AdminRegionResponse; +import com.example.solidconnection.admin.location.region.dto.AdminRegionUpdateRequest; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.location.region.repository.RegionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminRegionService { + + private final RegionRepository regionRepository; + + @Transactional(readOnly = true) + public List getAllRegions() { + return regionRepository.findAll() + .stream() + .map(AdminRegionResponse::from) + .toList(); + } + + @Transactional + public AdminRegionResponse createRegion(AdminRegionCreateRequest request) { + validateCodeNotExists(request.code()); + validateKoreanNameNotExists(request.koreanName()); + + Region region = new Region(request.code(), request.koreanName()); + Region savedRegion = regionRepository.save(region); + + return AdminRegionResponse.from(savedRegion); + } + + private void validateCodeNotExists(String code) { + regionRepository.findById(code) + .ifPresent(region -> { + throw new CustomException(ErrorCode.REGION_ALREADY_EXISTS); + }); + } + + private void validateKoreanNameNotExists(String koreanName) { + regionRepository.findByKoreanName(koreanName) + .ifPresent(region -> { + throw new CustomException(ErrorCode.REGION_ALREADY_EXISTS); + }); + } + + @Transactional + public AdminRegionResponse updateRegion(String code, AdminRegionUpdateRequest request) { + Region region = regionRepository.findById(code) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND)); + + validateKoreanNameNotDuplicated(request.koreanName(), code); + + region.updateKoreanName(request.koreanName()); + return AdminRegionResponse.from(region); + } + + private void validateKoreanNameNotDuplicated(String koreanName, String excludeCode) { + regionRepository.findByKoreanName(koreanName) + .ifPresent(existingRegion -> { + if (!existingRegion.getCode().equals(excludeCode)) { + throw new CustomException(ErrorCode.REGION_ALREADY_EXISTS); + } + }); + } + + @Transactional + public void deleteRegion(String code) { + Region region = regionRepository.findById(code) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND)); + + regionRepository.delete(region); + } +} diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index a1837d303..192b81340 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -38,7 +38,9 @@ public enum ErrorCode { APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "사용자의 대학 지원 정보를 찾을 수 없습니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "회원을 찾을 수 없습니다."), UNIVERSITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "대학교를 찾을 수 없습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "지역을 찾을 수 없습니다."), REGION_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 지역을 찾을 수 없습니다."), + REGION_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 지역입니다."), COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."), GPA_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학점입니다."), LANGUAGE_TEST_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 어학성적입니다."), diff --git a/src/main/java/com/example/solidconnection/location/region/domain/Region.java b/src/main/java/com/example/solidconnection/location/region/domain/Region.java index cb0d5ab7a..3a9953f99 100644 --- a/src/main/java/com/example/solidconnection/location/region/domain/Region.java +++ b/src/main/java/com/example/solidconnection/location/region/domain/Region.java @@ -25,4 +25,8 @@ public Region(String code, String koreanName) { this.code = code; this.koreanName = koreanName; } + + public void updateKoreanName(String koreanName) { + this.koreanName = koreanName; + } } diff --git a/src/main/java/com/example/solidconnection/location/region/repository/RegionRepository.java b/src/main/java/com/example/solidconnection/location/region/repository/RegionRepository.java index dea93fb34..eb2f290e1 100644 --- a/src/main/java/com/example/solidconnection/location/region/repository/RegionRepository.java +++ b/src/main/java/com/example/solidconnection/location/region/repository/RegionRepository.java @@ -5,7 +5,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface RegionRepository extends JpaRepository { +public interface RegionRepository extends JpaRepository { List findAllByKoreanNameIn(List koreanNames); diff --git a/src/test/java/com/example/solidconnection/admin/location/region/service/AdminRegionServiceTest.java b/src/test/java/com/example/solidconnection/admin/location/region/service/AdminRegionServiceTest.java new file mode 100644 index 000000000..1aeacdd44 --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/location/region/service/AdminRegionServiceTest.java @@ -0,0 +1,196 @@ +package com.example.solidconnection.admin.location.region.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.example.solidconnection.admin.location.region.dto.AdminRegionCreateRequest; +import com.example.solidconnection.admin.location.region.dto.AdminRegionResponse; +import com.example.solidconnection.admin.location.region.dto.AdminRegionUpdateRequest; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.location.region.fixture.RegionFixture; +import com.example.solidconnection.location.region.repository.RegionRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("지역 관련 관리자 서비스 테스트") +class AdminRegionServiceTest { + + @Autowired + private AdminRegionService adminRegionService; + + @Autowired + private RegionRepository regionRepository; + + @Autowired + private RegionFixture regionFixture; + + @Nested + class 전체_지역_조회 { + + @Test + void 지역이_없으면_빈_목록을_반환한다() { + // when + List responses = adminRegionService.getAllRegions(); + + // then + assertThat(responses).isEqualTo(List.of()); + } + + @Test + void 저장된_모든_지역을_조회한다() { + // given + Region region1 = regionFixture.영미권(); + Region region2 = regionFixture.유럽(); + Region region3 = regionFixture.아시아(); + + // when + List responses = adminRegionService.getAllRegions(); + + // then + assertThat(responses) + .hasSize(3) + .extracting(AdminRegionResponse::code) + .containsExactlyInAnyOrder( + region1.getCode(), + region2.getCode(), + region3.getCode() + ); + } + } + + @Nested + class 지역_생성 { + + @Test + void 유효한_정보로_지역을_생성하면_성공한다() { + // given + AdminRegionCreateRequest request = new AdminRegionCreateRequest("KR_SEOUL", "서울"); + + // when + AdminRegionResponse response = adminRegionService.createRegion(request); + + // then + assertThat(response.code()).isEqualTo("KR_SEOUL"); + + // 데이터베이스에 저장되었는지 확인 + Region savedRegion = regionRepository.findById(request.code()).orElseThrow(); + assertThat(savedRegion.getKoreanName()).isEqualTo(request.koreanName()); + } + + @Test + void 이미_존재하는_코드로_지역을_생성하면_예외_응답을_반환한다() { + // given + regionFixture.영미권(); + + AdminRegionCreateRequest request = new AdminRegionCreateRequest("AMERICAS", "새로운 영미권"); + + // when & then + assertThatCode(() -> adminRegionService.createRegion(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.REGION_ALREADY_EXISTS.getMessage()); + } + + @Test + void 이미_존재하는_한글명으로_지역을_생성하면_예외_응답을_반환한다() { + // given + regionFixture.유럽(); + + AdminRegionCreateRequest request = new AdminRegionCreateRequest("NEW_CODE", "유럽"); + + // when & then + assertThatCode(() -> adminRegionService.createRegion(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.REGION_ALREADY_EXISTS.getMessage()); + } + } + + @Nested + class 지역_수정 { + + @Test + void 유효한_정보로_지역을_수정하면_성공한다() { + // given + Region region = regionFixture.영미권(); + + AdminRegionUpdateRequest request = new AdminRegionUpdateRequest("미주"); + + // when + AdminRegionResponse response = adminRegionService.updateRegion(region.getCode(), request); + + // then + assertThat(response.code()).isEqualTo(region.getCode()); + Region updatedRegion = regionRepository.findById(region.getCode()).orElseThrow(); + assertThat(updatedRegion.getKoreanName()).isEqualTo(request.koreanName()); + } + + @Test + void 존재하지_않는_지역_코드로_수정하면_예외_응답을_반환한다() { + // given + AdminRegionUpdateRequest request = new AdminRegionUpdateRequest("부산"); + + // when & then + assertThatCode(() -> adminRegionService.updateRegion("NOT_EXIST", request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.REGION_NOT_FOUND.getMessage()); + } + + @Test + void 다른_지역의_한글명으로_수정하면_예외_응답을_반환한다() { + // given + Region region1 = regionFixture.영미권(); + Region region2 = regionFixture.유럽(); + + AdminRegionUpdateRequest request = new AdminRegionUpdateRequest(region2.getKoreanName()); + + // when & then + assertThatCode(() -> adminRegionService.updateRegion(region1.getCode(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.REGION_ALREADY_EXISTS.getMessage()); + } + + @Test + void 같은_지역의_한글명으로_수정하면_성공한다() { + // given + Region region = regionFixture.아시아(); + + AdminRegionUpdateRequest request = new AdminRegionUpdateRequest(region.getKoreanName()); + + // when + AdminRegionResponse response = adminRegionService.updateRegion(region.getCode(), request); + + // then + assertThat(response.code()).isEqualTo(region.getCode()); + } + } + + @Nested + class 지역_삭제 { + + @Test + void 존재하는_지역을_삭제하면_성공한다() { + // given + Region region = regionFixture.영미권(); + + // when + adminRegionService.deleteRegion(region.getCode()); + + // then + assertThat(regionRepository.findById(region.getCode())).isEmpty(); + } + + @Test + void 존재하지_않는_지역을_삭제하면_예외_응답을_반환한다() { + // when & then + assertThatCode(() -> adminRegionService.deleteRegion("NOT_EXIST")) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.REGION_NOT_FOUND.getMessage()); + } + } +} From 48ebb9e46c291cacce63d85c6621eccc0fba4567 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:29:51 +0900 Subject: [PATCH 17/31] =?UTF-8?q?fix:=20config.alloy=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: config.alloy 파일 경로 불일치 문제 해결 * refactor: docker-compose를 수정하는게 아닌 cd.yml의 경로를 수정하여 해결 --- .github/workflows/dev-cd.yml | 12 +++++++++++- .github/workflows/prod-cd.yml | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 38a274143..c1c7f42da 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -135,8 +135,18 @@ jobs: echo "Pulling new image: $FULL_IMAGE_NAME" docker pull $FULL_IMAGE_NAME - # 4. 작업 및 Nginx 설정 적용 + # 4. alloy 설정 및 Nginx 설정 적용 cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev + + mkdir -p ./docs/infra-config + + if [ -d "./docs/infra-config/config.alloy" ]; then + echo "Removing directory created by Docker..." + rm -rf ./docs/infra-config/config.alloy + fi + + mv -f ./config.alloy ./docs/infra-config/config.alloy + mkdir -p ./nginx mv ./nginx.dev.conf ./nginx/default.conf sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index e05e1d0ac..a7dda5ab4 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -150,8 +150,18 @@ jobs: echo "Pulling new image: $FULL_IMAGE_NAME" docker pull $FULL_IMAGE_NAME - # 4. Nginx 설정 적용 + # 4. alloy 설정 및 Nginx 설정 적용 cd /home/${{ secrets.USERNAME }}/solid-connection-prod + + mkdir -p ./docs/infra-config + + if [ -d "./docs/infra-config/config.alloy" ]; then + echo "Removing directory created by Docker..." + rm -rf ./docs/infra-config/config.alloy + fi + + mv -f ./config.alloy ./docs/infra-config/config.alloy + mkdir -p ./nginx mv ./nginx.prod.conf ./nginx/default.conf sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf From 6ec2ab146bfb461ffdf1122c53021432de52a081 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:04:48 +0900 Subject: [PATCH 18/31] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=8A=94=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=84=20=ED=95=A0=20=EC=88=98=20=EC=97=86=EB=8B=A4.=20(#582?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 소셜 로그인 사용자는 비밀번호 변경을 할 수 없도록 * test: 소셜 로그인 사용자 비밀번호 변경 관련 테스트 코드 작성 * chore: 컨벤션에 맞게 메서드명 변경 - ~~ 예외가 발생한다 * chore: 충돌 해결 --- .../common/exception/ErrorCode.java | 1 + .../siteuser/service/MyPageService.java | 6 +++++ src/main/resources/secret | 2 +- .../siteuser/service/MyPageServiceTest.java | 24 +++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 192b81340..9ab95778a 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -67,6 +67,7 @@ public enum ErrorCode { PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST.value(), "현재 비밀번호와 새 비밀번호가 동일합니다."), PASSWORD_NOT_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "새 비밀번호가 일치하지 않습니다."), SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."), + OAUTH_USER_CANNOT_CHANGE_PASSWORD(HttpStatus.BAD_REQUEST.value(), "소셜 로그인 사용자는 비밀번호를 변경할 수 없습니다."), // s3 S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"), diff --git a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java index d48de9bfa..6e8b88b66 100644 --- a/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java +++ b/src/main/java/com/example/solidconnection/siteuser/service/MyPageService.java @@ -3,6 +3,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.common.exception.ErrorCode.OAUTH_USER_CANNOT_CHANGE_PASSWORD; import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH; import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; @@ -16,6 +17,7 @@ import com.example.solidconnection.s3.domain.ImgType; import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.LocationUpdateRequest; @@ -126,6 +128,10 @@ public void updatePassword(long siteUserId, PasswordUpdateRequest request) { SiteUser user = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + if (!AuthType.isEmail(user.getAuthType())) { + throw new CustomException(OAUTH_USER_CANNOT_CHANGE_PASSWORD); + } + // 사용자의 비밀번호와 request의 currentPassword가 동일한지 검증 validatePasswordMatch(request.currentPassword(), user.getPassword()); diff --git a/src/main/resources/secret b/src/main/resources/secret index 8300cdeca..e369d00c8 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 8300cdecaebfc28fd657064a00a44815a7bb2eee +Subproject commit e369d00c8661efc1fe9f4f1e8b346df028de80bd diff --git a/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java index fa83522e7..3a82681f3 100644 --- a/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/service/MyPageServiceTest.java @@ -1,6 +1,7 @@ package com.example.solidconnection.siteuser.service; import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; +import static com.example.solidconnection.common.exception.ErrorCode.OAUTH_USER_CANNOT_CHANGE_PASSWORD; import static com.example.solidconnection.common.exception.ErrorCode.PASSWORD_MISMATCH; import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; import static com.example.solidconnection.siteuser.service.MyPageService.NICKNAME_LAST_CHANGE_DATE_FORMAT; @@ -49,6 +50,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; @@ -328,6 +331,27 @@ void setUp() { .isInstanceOf(CustomException.class) .hasMessage(PASSWORD_MISMATCH.getMessage()); } + + @ParameterizedTest + @EnumSource(value = AuthType.class, names = {"KAKAO", "APPLE"}) + void 소셜_로그인_사용자가_비밀번호를_변경하면_예외가_발생한다(AuthType authType) { + // given + SiteUser oauthUser = siteUserFixtureBuilder.siteUser() + .email("oauth@example.com") + .authType(authType) + .nickname("소셜로그인사용자") + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("randomPassword") + .create(); + + PasswordUpdateRequest request = new PasswordUpdateRequest("anyPassword", "newPassword", "newPassword"); + + // when & then + assertThatThrownBy(() -> myPageService.updatePassword(oauthUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(OAUTH_USER_CANNOT_CHANGE_PASSWORD.getMessage()); + } } @Nested From 1f12fea67d1c570f9dc7a1d0c8178142192396a6 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:49:14 +0900 Subject: [PATCH 19/31] =?UTF-8?q?fix:=20Upgrade=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=9C=A0=EB=AC=B4=EC=97=90=20=EB=94=B0=EB=9D=BC=20Connection?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=EC=9D=98=20=EA=B0=92=EC=9D=84=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=84=A4=EC=A0=95=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20(#581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Upgrade 헤더 유무에 따라 Connection 헤더의 값을 동적으로 설정하도록 - Upgrade 헤더가 존재하면(e.g. WebSocket) upgrade로 설정 - Upgrade 헤더가 존재하지 않으면 keep-alive로 설정 * chore: 서브모듈 업데이트 --- docs/infra-config/nginx.dev.conf | 7 ++++++- docs/infra-config/nginx.prod.conf | 7 ++++++- src/main/resources/secret | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/infra-config/nginx.dev.conf b/docs/infra-config/nginx.dev.conf index d683cf677..ae3a35a47 100644 --- a/docs/infra-config/nginx.dev.conf +++ b/docs/infra-config/nginx.dev.conf @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' keep-alive; +} + server { listen 80; server_name api.stage.solid-connection.com; @@ -40,6 +45,6 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; } } diff --git a/docs/infra-config/nginx.prod.conf b/docs/infra-config/nginx.prod.conf index abe128067..ad0d81792 100644 --- a/docs/infra-config/nginx.prod.conf +++ b/docs/infra-config/nginx.prod.conf @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' keep-alive; +} + server { listen 80; server_name api.solid-connection.com; @@ -31,6 +36,6 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; } } diff --git a/src/main/resources/secret b/src/main/resources/secret index e369d00c8..812db022a 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit e369d00c8661efc1fe9f4f1e8b346df028de80bd +Subproject commit 812db022ab3f2d83b43cfedae3ba57518d334a9c From 2cf03ee6753ef0ec9a2157dcf9c0a92276f7d7c3 Mon Sep 17 00:00:00 2001 From: hyungjun <115551339+sukangpunch@users.noreply.github.com> Date: Sat, 27 Dec 2025 17:15:05 +0900 Subject: [PATCH 20/31] =?UTF-8?q?feat:=20=EB=A9=98=ED=86=A0=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20=EB=8C=80=ED=95=99=EA=B5=90=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EA=B8=B0=EB=8A=A5,=20=EB=8C=80=ED=95=99=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80=20(#583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 멘토 지원서 검색 조건에 UniversitySelectType 추가 * feat: 어드민 멘토 지원서 페이징 조회 응답에 UniversitySelectType 추가 * test: 멘토 지원서 조회 테스트 추가 - test: UniversitySelectType 기반 페이징 조회 테스트 추가 * feat: 멘토 지원서에 대학 매핑 기능 추가 * test: 멘토 지원서 대학 매핑 테스트 추가 * refactor: 의미 없는 import 제거 * refactor: 리뷰 내용 반영 * refactor: 개행 및 공백 추가 * refactor: pathVariable 네이밍을 kebab-case 로 통일 * refactor: Service 레이어의 검증 로직을 도메인으로 이동 * refactor: PENDING 상태 및 OTHER 타입 검증을 도메인 메서드로 관리 * refactor: assignUniversity() 호출 전 검증 책임을 도메인 엔티티에 위임 * test : assertAll 로 검증 그룹화 --- .../AdminMentorApplicationController.java | 19 +- ...torApplicationAssignUniversityRequest.java | 10 + .../admin/dto/MentorApplicationResponse.java | 2 + .../dto/MentorApplicationSearchCondition.java | 6 +- .../AdminMentorApplicationService.java | 28 +- .../common/exception/ErrorCode.java | 1 + .../mentor/domain/MentorApplication.java | 33 ++- ...MentorApplicationFilterRepositoryImpl.java | 12 +- .../AdminMentorApplicationServiceTest.java | 272 +++++++++++++----- 9 files changed, 294 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/admin/dto/MentorApplicationAssignUniversityRequest.java diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java index 1c64df145..b8fe69742 100644 --- a/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java @@ -1,5 +1,6 @@ package com.example.solidconnection.admin.controller; +import com.example.solidconnection.admin.dto.MentorApplicationAssignUniversityRequest; import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; @@ -40,17 +41,17 @@ public ResponseEntity> searchMento return ResponseEntity.ok(PageResponse.of(page)); } - @PostMapping("/{mentorApplicationId}/approve") + @PostMapping("/{mentor-application-id}/approve") public ResponseEntity approveMentorApplication( - @PathVariable("mentorApplicationId") Long mentorApplicationId + @PathVariable("mentor-application-id") Long mentorApplicationId ) { adminMentorApplicationService.approveMentorApplication(mentorApplicationId); return ResponseEntity.ok().build(); } - @PostMapping("/{mentorApplicationId}/reject") + @PostMapping("/{mentor-application-id}/reject") public ResponseEntity rejectMentorApplication( - @PathVariable("mentorApplicationId") Long mentorApplicationId, + @PathVariable("mentor-application-id") Long mentorApplicationId, @Valid @RequestBody MentorApplicationRejectRequest request ) { adminMentorApplicationService.rejectMentorApplication(mentorApplicationId, request); @@ -62,4 +63,14 @@ public ResponseEntity getMentorApplicationCount( MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount(); return ResponseEntity.ok(response); } + + @PostMapping("/{mentor-application-id}/assign-university") + public ResponseEntity assignUniversity( + @PathVariable("mentor-application-id") Long mentorApplicationId, + @Valid @RequestBody MentorApplicationAssignUniversityRequest request + ) { + Long universityId = request.universityId(); + adminMentorApplicationService.assignUniversity(mentorApplicationId, universityId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationAssignUniversityRequest.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationAssignUniversityRequest.java new file mode 100644 index 000000000..6fdd8c6e9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationAssignUniversityRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import jakarta.validation.constraints.NotNull; + +public record MentorApplicationAssignUniversityRequest( + @NotNull(message = "대학 ID 는 필수입니다.") + Long universityId +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java index 1fafc2df4..5b6d6cc10 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationResponse.java @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.dto; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import com.example.solidconnection.mentor.domain.UniversitySelectType; import java.time.ZonedDateTime; public record MentorApplicationResponse( @@ -8,6 +9,7 @@ public record MentorApplicationResponse( String region, String country, String university, + UniversitySelectType universitySelectType, String mentorProofUrl, MentorApplicationStatus mentorApplicationStatus, String rejectedReason, diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java index 9871ba6d6..940d2369c 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationSearchCondition.java @@ -1,12 +1,14 @@ package com.example.solidconnection.admin.dto; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import com.example.solidconnection.mentor.domain.UniversitySelectType; import java.time.LocalDate; public record MentorApplicationSearchCondition( MentorApplicationStatus mentorApplicationStatus, String keyword, - LocalDate createdAt + LocalDate createdAt, + UniversitySelectType universitySelectType ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java index 2c8ab4949..d22d9af7f 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -1,7 +1,6 @@ package com.example.solidconnection.admin.service; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; -import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED; import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; @@ -11,6 +10,8 @@ import com.example.solidconnection.mentor.domain.MentorApplication; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import com.example.solidconnection.mentor.repository.MentorApplicationRepository; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.repository.UniversityRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -22,6 +23,7 @@ public class AdminMentorApplicationService { private final MentorApplicationRepository mentorApplicationRepository; + private final UniversityRepository universityRepository; @Transactional(readOnly = true) public Page searchMentorApplications( @@ -36,15 +38,14 @@ public void approveMentorApplication(Long mentorApplicationId) { MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId) .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); - if(mentorApplication.getUniversityId() == null){ - throw new CustomException(MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED); - } - mentorApplication.approve(); } @Transactional - public void rejectMentorApplication(long mentorApplicationId, MentorApplicationRejectRequest request) { + public void rejectMentorApplication( + long mentorApplicationId, + MentorApplicationRejectRequest request + ) { MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId) .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); @@ -63,4 +64,19 @@ public MentorApplicationCountResponse getMentorApplicationCount() { rejectedCount ); } + + @Transactional + public void assignUniversity( + Long mentorApplicationId, + Long universityId + ) { + MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId) + .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); + + mentorApplication.validateCanAssignUniversity(); + + University university = universityRepository.getUniversityById(universityId); + + mentorApplication.assignUniversity(university.getId()); + } } diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 9ab95778a..07141979b 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -134,6 +134,7 @@ public enum ErrorCode { MENTOR_ALREADY_EXISTS(HttpStatus.BAD_REQUEST.value(), "이미 존재하는 멘토입니다."), MENTOR_APPLICATION_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토 승격 요청 입니다."), MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED(HttpStatus.BAD_REQUEST.value(), "승인하려는 멘토 신청에 대학교가 선택되지 않았습니다."), + MENTOR_APPLICATION_NOT_OTHER_STATUS(HttpStatus.BAD_REQUEST.value(), "대학 선택 타입이 OTHER이 아닌 멘토 지원서 입니다."), // socket UNAUTHORIZED_SUBSCRIBE(HttpStatus.FORBIDDEN.value(), "구독 권한이 없습니다."), diff --git a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java index 502ae1237..fe4f2ccb1 100644 --- a/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java +++ b/src/main/java/com/example/solidconnection/mentor/domain/MentorApplication.java @@ -1,6 +1,8 @@ package com.example.solidconnection.mentor.domain; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_OTHER_STATUS; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED; import static java.time.ZoneOffset.UTC; import static java.time.temporal.ChronoUnit.MICROS; @@ -122,18 +124,39 @@ private void validateExchangeStatus(ExchangeStatus exchangeStatus) { } public void approve(){ - if(this.mentorApplicationStatus != MentorApplicationStatus.PENDING) { - throw new CustomException(MENTOR_APPLICATION_ALREADY_CONFIRMED); - } + validatePending(); + validateCanApprove(); this.mentorApplicationStatus = MentorApplicationStatus.APPROVED; this.approvedAt = ZonedDateTime.now(UTC).truncatedTo(MICROS); } + private void validateCanApprove(){ + if(this.universitySelectType != UniversitySelectType.CATALOG){ + throw new CustomException(MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED); + } + } + public void reject(String rejectedReason){ + validatePending(); + this.mentorApplicationStatus = MentorApplicationStatus.REJECTED; + this.rejectedReason = rejectedReason; + } + + public void assignUniversity(long universityId) { + this.universityId = universityId; + this.universitySelectType = UniversitySelectType.CATALOG; + } + + public void validateCanAssignUniversity(){ + validatePending(); + if(this.universitySelectType != UniversitySelectType.OTHER){ + throw new CustomException(MENTOR_APPLICATION_NOT_OTHER_STATUS); + } + } + + private void validatePending(){ if(this.mentorApplicationStatus != MentorApplicationStatus.PENDING) { throw new CustomException(MENTOR_APPLICATION_ALREADY_CONFIRMED); } - this.mentorApplicationStatus = MentorApplicationStatus.REJECTED; - this.rejectedReason = rejectedReason; } } diff --git a/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java index 788bda8a5..38dc0b6e4 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/custom/MentorApplicationFilterRepositoryImpl.java @@ -12,6 +12,7 @@ import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; import com.example.solidconnection.admin.dto.SiteUserResponse; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import com.example.solidconnection.mentor.domain.UniversitySelectType; import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; @@ -48,6 +49,7 @@ public class MentorApplicationFilterRepositoryImpl implements MentorApplicationF region.koreanName, country.koreanName, university.koreanName, + mentorApplication.universitySelectType, mentorApplication.mentorProofUrl, mentorApplication.mentorApplicationStatus, mentorApplication.rejectedReason, @@ -81,7 +83,8 @@ public Page searchMentorApplications(MentorAppl .where( verifyMentorStatusEq(condition.mentorApplicationStatus()), keywordContains(condition.keyword()), - createdAtEq(condition.createdAt()) + createdAtEq(condition.createdAt()), + universitySelectTypeEq(condition.universitySelectType()) ) .orderBy(mentorApplication.createdAt.desc()) .offset(pageable.getOffset()) @@ -110,7 +113,8 @@ private JPAQuery createCountQuery(MentorApplicationSearchCondition conditi return query.where( verifyMentorStatusEq(condition.mentorApplicationStatus()), keywordContains(condition.keyword()), - createdAtEq(condition.createdAt()) + createdAtEq(condition.createdAt()), + universitySelectTypeEq(condition.universitySelectType()) ); } @@ -142,4 +146,8 @@ private BooleanExpression createdAtEq(LocalDate createdAt) { endOfDay.atZone(SYSTEM_ZONE_ID) ); } + + private BooleanExpression universitySelectTypeEq(UniversitySelectType universitySelectType) { + return universitySelectType != null ? mentorApplication.universitySelectType.eq(universitySelectType) : null; + } } diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java index dd844d866..ae40cb5d7 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java @@ -2,7 +2,9 @@ import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_OTHER_STATUS; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED; +import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; @@ -58,6 +60,8 @@ class AdminMentorApplicationServiceTest { private MentorApplication mentorApplication4; private MentorApplication mentorApplication5; private MentorApplication mentorApplication6; + private MentorApplication mentorApplication7; + private MentorApplication mentorApplication8; @BeforeEach void setUp() { @@ -67,6 +71,8 @@ void setUp() { SiteUser user4 = siteUserFixture.사용자(4, "test4"); SiteUser user5 = siteUserFixture.사용자(5, "test5"); SiteUser user6 = siteUserFixture.사용자(6, "test6"); + SiteUser user7 = siteUserFixture.사용자(7, "test7"); + SiteUser user8 = siteUserFixture.사용자(8, "test8"); University university1 = universityFixture.메이지_대학(); University university2 = universityFixture.괌_대학(); University university3 = universityFixture.그라츠_대학(); @@ -76,6 +82,8 @@ void setUp() { mentorApplication4 = mentorApplicationFixture.승인된_멘토신청(user4.getId(), UniversitySelectType.CATALOG, university3.getId()); mentorApplication5 = mentorApplicationFixture.대기중_멘토신청(user5.getId(), UniversitySelectType.CATALOG, university1.getId()); mentorApplication6 = mentorApplicationFixture.거절된_멘토신청(user6.getId(), UniversitySelectType.CATALOG, university2.getId()); + mentorApplication7 = mentorApplicationFixture.대기중_멘토신청(user7.getId(), UniversitySelectType.OTHER, null); + mentorApplication8 = mentorApplicationFixture.거절된_멘토신청(user8.getId(), UniversitySelectType.OTHER, null); } @Nested @@ -84,30 +92,32 @@ class 멘토_승격_지원서_목록_조회 { @Test void 멘토_승격_상태를_조건으로_페이징하여_조회한다() { // given - MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(MentorApplicationStatus.PENDING,null, null); + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(MentorApplicationStatus.PENDING,null, null, null); Pageable pageable = PageRequest.of(0, 10); - List expectedMentorApplications = List.of(mentorApplication2, mentorApplication5); + List expectedMentorApplications = List.of(mentorApplication2, mentorApplication5, mentorApplication7); // when Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); // then - assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().id()) - .containsOnly(expectedMentorApplications.stream() - .map(MentorApplication::getId) - .toArray(Long[]::new)); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().mentorApplicationStatus()) - .containsOnly(MentorApplicationStatus.PENDING); + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().mentorApplicationStatus()) + .containsOnly(MentorApplicationStatus.PENDING) + ); } @Test void 닉네임_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ // given String nickname = "test1"; - MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, nickname, null); + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, nickname, null, null); Pageable pageable = PageRequest.of(0, 10); List expectedMentorApplications = List.of(mentorApplication1); @@ -115,22 +125,24 @@ class 멘토_승격_지원서_목록_조회 { Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); // then - assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().id()) - .containsOnly(expectedMentorApplications.stream() - .map(MentorApplication::getId) - .toArray(Long[]::new)); - assertThat(response.getContent()) - .extracting(content -> content.siteUserResponse().nickname()) - .containsOnly(nickname); + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.siteUserResponse().nickname()) + .containsOnly(nickname) + ); } @Test void 대학명_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ // given String universityKoreanName = "메이지 대학"; - MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, universityKoreanName, null); + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, universityKoreanName, null, null); Pageable pageable = PageRequest.of(0, 10); List expectedMentorApplications = List.of(mentorApplication1, mentorApplication5); @@ -138,22 +150,24 @@ class 멘토_승격_지원서_목록_조회 { Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); // then - assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().id()) - .containsOnly(expectedMentorApplications.stream() - .map(MentorApplication::getId) - .toArray(Long[]::new)); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().university()) - .containsOnly(universityKoreanName); + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().university()) + .containsOnly(universityKoreanName) + ); } @Test void 지역명_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ // given String regionKoreanName = "유럽"; - MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, regionKoreanName, null); + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, regionKoreanName, null, null); Pageable pageable = PageRequest.of(0, 10); List expectedMentorApplications = List.of(mentorApplication3, mentorApplication4); @@ -161,22 +175,24 @@ class 멘토_승격_지원서_목록_조회 { Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); // then - assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().id()) - .containsOnly(expectedMentorApplications.stream() - .map(MentorApplication::getId) - .toArray(Long[]::new)); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().region()) - .containsOnly(regionKoreanName); + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().region()) + .containsOnly(regionKoreanName) + ); } @Test void 나라명_keyword_에_맞는_멘토_지원서를_페이징하여_조회한다(){ // given String countryKoreanName = "오스트리아"; - MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, countryKoreanName, null); + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, countryKoreanName, null,null); Pageable pageable = PageRequest.of(0, 10); List expectedMentorApplications = List.of(mentorApplication3, mentorApplication4); @@ -184,22 +200,75 @@ class 멘토_승격_지원서_목록_조회 { Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); // then - assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().id()) - .containsOnly(expectedMentorApplications.stream() - .map(MentorApplication::getId) - .toArray(Long[]::new)); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().country()) - .containsOnly(countryKoreanName); + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().country()) + .containsOnly(countryKoreanName) + ); + } + + @Test + void CATALOG_타입의_멘토_지원서만_조회한다() { + // given + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, null, null, UniversitySelectType.CATALOG); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of( + mentorApplication1, mentorApplication2, mentorApplication3, + mentorApplication4, mentorApplication5, mentorApplication6 + ); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().universitySelectType()) + .containsOnly(UniversitySelectType.CATALOG) + ); + } + + @Test + void OTHER_타입의_멘토_지원서만_조회한다() { + // given + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(null, null, null, UniversitySelectType.OTHER); + Pageable pageable = PageRequest.of(0, 10); + List expectedMentorApplications = List.of(mentorApplication7, mentorApplication8); + + // when + Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); + + // then + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().universitySelectType()) + .containsOnly(UniversitySelectType.OTHER) + ); } @Test void 모든_조건으로_페이징하여_조회한다() { // given String regionKoreanName = "영미권"; - MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(MentorApplicationStatus.PENDING, regionKoreanName, LocalDate.now()); + MentorApplicationSearchCondition condition = new MentorApplicationSearchCondition(MentorApplicationStatus.PENDING, regionKoreanName, LocalDate.now(), UniversitySelectType.CATALOG); Pageable pageable = PageRequest.of(0, 10); List expectedMentorApplications = List.of(mentorApplication2); @@ -207,18 +276,20 @@ class 멘토_승격_지원서_목록_조회 { Page response = adminMentorApplicationService.searchMentorApplications(condition, pageable); // then - assertThat(response.getContent()).hasSize(expectedMentorApplications.size()); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().id()) - .containsOnly(expectedMentorApplications.stream() - .map(MentorApplication::getId) - .toArray(Long[]::new)); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().mentorApplicationStatus()) - .containsOnly(MentorApplicationStatus.PENDING); - assertThat(response.getContent()) - .extracting(content -> content.mentorApplicationResponse().region()) - .containsOnly(regionKoreanName); + assertAll( + () -> assertThat(response.getContent()).hasSize(expectedMentorApplications.size()), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().id()) + .containsOnly(expectedMentorApplications.stream() + .map(MentorApplication::getId) + .toArray(Long[]::new)), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().mentorApplicationStatus()) + .containsOnly(MentorApplicationStatus.PENDING), + () -> assertThat(response.getContent()) + .extracting(content -> content.mentorApplicationResponse().region()) + .containsOnly(regionKoreanName) + ); } } @@ -235,8 +306,10 @@ class 멘토_승격_지원서_승인{ // then MentorApplication result = mentorApplicationRepository.findById(mentorApplication2.getId()).get(); - assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.APPROVED); - assertThat(result.getApprovedAt()).isNotNull(); + assertAll( + () -> assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.APPROVED), + () -> assertThat(result.getApprovedAt()).isNotNull() + ); } @Test @@ -299,8 +372,10 @@ class 멘토_승격_지원서_거절{ // then MentorApplication result = mentorApplicationRepository.findById(mentorApplication2.getId()).get(); - assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.REJECTED); - assertThat(result.getRejectedReason()).isEqualTo(request.rejectedReason()); + assertAll( + () -> assertThat(result.getMentorApplicationStatus()).isEqualTo(MentorApplicationStatus.REJECTED), + () -> assertThat(result.getRejectedReason()).isEqualTo(request.rejectedReason()) + ); } @Test @@ -347,8 +422,8 @@ class 멘토_지원서_상태별_개수_조회 { void 상태별_멘토_지원서_개수를_조회한다() { // given List expectedApprovedCount = List.of(mentorApplication1, mentorApplication4); - List expectedPendingCount = List.of(mentorApplication2, mentorApplication5); - List expectedRejectedCount = List.of(mentorApplication3, mentorApplication6); + List expectedPendingCount = List.of(mentorApplication2, mentorApplication5, mentorApplication7); + List expectedRejectedCount = List.of(mentorApplication3, mentorApplication6, mentorApplication8); // when MentorApplicationCountResponse response = adminMentorApplicationService.getMentorApplicationCount(); @@ -377,4 +452,61 @@ class 멘토_지원서_상태별_개수_조회 { ); } } + + @Nested + class 멘토_지원서에_대학_매핑 { + + @Test + void OTHER_타입의_멘토_지원서에_대학을_매핑하면_대학이_할당되고_타입이_CATALOG로_변경된다() { + // given + long otherTypeMentorApplicationId = mentorApplication7.getId(); + University university = universityFixture.메이지_대학(); + + // when + adminMentorApplicationService.assignUniversity(otherTypeMentorApplicationId, university.getId()); + + // then + MentorApplication result = mentorApplicationRepository.findById(otherTypeMentorApplicationId).get(); + assertAll( + () -> assertThat(result.getUniversityId()).isEqualTo(university.getId()), + () -> assertThat(result.getUniversitySelectType()).isEqualTo(UniversitySelectType.CATALOG) + ); + } + + @Test + void 존재하지_않는_멘토_지원서에_대학을_매핑하면_예외_응답을_반환한다() { + // given + long nonExistentId = 99999L; + University university = universityFixture.메이지_대학(); + + // when & then + assertThatCode(() -> adminMentorApplicationService.assignUniversity(nonExistentId, university.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_NOT_FOUND.getMessage()); + } + + @Test + void CATALOG_타입의_멘토_지원서에_대학을_매핑하면_예외_응답을_반환한다() { + // given + long catalogTypeMentorApplicationId = mentorApplication2.getId(); + University university = universityFixture.메이지_대학(); + + // when & then + assertThatCode(() -> adminMentorApplicationService.assignUniversity(catalogTypeMentorApplicationId, university.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(MENTOR_APPLICATION_NOT_OTHER_STATUS.getMessage()); + } + + @Test + void 존재하지_않는_대학을_매핑하면_예외_응답을_반환한다() { + // given + long otherTypeMentorApplicationId = mentorApplication7.getId(); + long nonExistentUniversityId = 99999L; + + // when & then + assertThatCode(() -> adminMentorApplicationService.assignUniversity(otherTypeMentorApplicationId, nonExistentUniversityId)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIVERSITY_NOT_FOUND.getMessage()); + } + } } From c30edc7a95a03c66f33d504992e2b75a1bf4e7be Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:08:55 +0900 Subject: [PATCH 21/31] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=A7=81?= =?UTF-8?q?=20=EB=B6=80=ED=8A=B8=20=EC=95=B1=20=EC=99=B8=EC=9D=98=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EA=B3=BC=EC=A0=95=EC=9D=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#592)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: dev 환경에서의 side-infra 배포 과정 분리 * refactor: prod 환경에서의 side-infra 배포 과정 분리 * refactor: docker-compose 가 실행되고 있지 않아도 스크립트가 실패하지 않게 변경 * fix: docker compose up 시에 사용할 환경변수 중 누락된 변수를 추가 --- .github/workflows/dev-cd.yml | 37 +++++++---------------------------- .github/workflows/prod-cd.yml | 29 ++++----------------------- docker-compose.dev.yml | 36 +++------------------------------- docker-compose.prod.yml | 36 +++------------------------------- 4 files changed, 17 insertions(+), 121 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index c1c7f42da..9f5f33358 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -108,14 +108,11 @@ jobs: scp -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ./docker-compose.dev.yml \ - ./docs/infra-config/config.alloy \ - ./docs/infra-config/nginx.dev.conf \ ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }}:/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/ # --- 서버에서 Docker Pull 및 재시작 --- - - name: Run docker compose and apply nginx config + - name: Run deployment on server run: | - # GITHUB_TOKEN을 이용해 서버에서 로그인 (App Token 불필요) ssh -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }} \ @@ -127,38 +124,18 @@ jobs: export IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG_ONLY}" - # 2. 서버가 GHCR에 로그인 (GITHUB_TOKEN 사용) - # App Token 대신 현재 워크플로우의 임시 토큰을 넘겨줍니다. + # 2. GHCR 로그인 & Pull echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - - # 3. Docker Pull echo "Pulling new image: $FULL_IMAGE_NAME" docker pull $FULL_IMAGE_NAME - # 4. alloy 설정 및 Nginx 설정 적용 - cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev - - mkdir -p ./docs/infra-config - - if [ -d "./docs/infra-config/config.alloy" ]; then - echo "Removing directory created by Docker..." - rm -rf ./docs/infra-config/config.alloy - fi - - mv -f ./config.alloy ./docs/infra-config/config.alloy - - mkdir -p ./nginx - mv ./nginx.dev.conf ./nginx/default.conf - sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf - sudo nginx -t - sudo nginx -s reload - - # 5. Docker Compose 재시작 + # 3. Spring Boot 앱 재시작 echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" - docker compose -f docker-compose.dev.yml down - IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.dev.yml up -d + cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev + docker compose -f docker-compose.dev.yml down || true + OWNER_LOWERCASE=$OWNER_LOWERCASE IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.dev.yml up -d - # 6. 정리 작업 + # 4. 정리 작업 echo "Pruning dangling images..." docker image prune -f diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index a7dda5ab4..47c4d2ea3 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -124,8 +124,6 @@ jobs: scp -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ./docker-compose.prod.yml \ - ./docs/infra-config/config.alloy \ - ./docs/infra-config/nginx.prod.conf \ ${{ secrets.USERNAME }}@${{ secrets.HOST }}:/home/${{ secrets.USERNAME }}/solid-connection-prod/ # --- 서버에서 Docker Pull 및 재시작 --- @@ -142,35 +140,16 @@ jobs: export IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG_ONLY}" - # 2. 서버가 GHCR에 로그인 (GITHUB_TOKEN 사용) + # 2. GHCR 로그인 & Pull # App Token 대신 현재 워크플로우의 임시 토큰을 사용합니다. echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - - # 3. docker pull echo "Pulling new image: $FULL_IMAGE_NAME" docker pull $FULL_IMAGE_NAME - # 4. alloy 설정 및 Nginx 설정 적용 - cd /home/${{ secrets.USERNAME }}/solid-connection-prod - - mkdir -p ./docs/infra-config - - if [ -d "./docs/infra-config/config.alloy" ]; then - echo "Removing directory created by Docker..." - rm -rf ./docs/infra-config/config.alloy - fi - - mv -f ./config.alloy ./docs/infra-config/config.alloy - - mkdir -p ./nginx - mv ./nginx.prod.conf ./nginx/default.conf - sudo cp ./nginx/default.conf /etc/nginx/conf.d/default.conf - sudo nginx -t - sudo nginx -s reload - - # 5. Docker Compose 재시작 + # 3. Spring Boot 앱 재시작 echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" - docker compose -f docker-compose.prod.yml down + cd /home/${{ secrets.USERNAME }}/solid-connection-prod + docker compose -f docker-compose.prod.yml down || true OWNER_LOWERCASE=$OWNER_LOWERCASE IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.prod.yml up -d # 6. 정리 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 29aaf5bb1..02554521f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,44 +1,14 @@ version: '3.8' services: - redis: - image: redis:latest - container_name: redis - ports: - - "6379:6379" - - redis-exporter: - image: oliver006/redis_exporter - container_name: redis-exporter - ports: - - "9121:9121" - environment: - REDIS_ADDR: "redis:6379" - depends_on: - - redis - solid-connection-dev: image: ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG:-latest} container_name: solid-connection-dev - ports: - - "8080:8080" - - "8081:8081" + network_mode: "host" environment: - SPRING_PROFILES_ACTIVE=dev - - SPRING_DATA_REDIS_HOST=redis + - SPRING_DATA_REDIS_HOST=127.0.0.1 - SPRING_DATA_REDIS_PORT=6379 volumes: - ./logs:/var/log/spring - depends_on: - - redis - - alloy: - image: grafana/alloy:latest - container_name: alloy - ports: - - "12345:12345" - volumes: - - ./logs:/var/log/spring - - ./docs/infra-config/config.alloy:/etc/alloy/config.alloy:ro - environment: - - ALLOY_ENV=dev + restart: always diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5b26eecf9..77afba397 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,44 +1,14 @@ version: '3.8' services: - redis: - image: redis:latest - container_name: redis - ports: - - "6379:6379" - - redis-exporter: - image: oliver006/redis_exporter - container_name: redis-exporter - ports: - - "9121:9121" - environment: - REDIS_ADDR: "redis:6379" - depends_on: - - redis - solid-connection-server: image: ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG:-latest} container_name: solid-connection-server - ports: - - "8080:8080" - - "8081:8081" + network_mode: "host" environment: - SPRING_PROFILES_ACTIVE=prod - - SPRING_DATA_REDIS_HOST=redis + - SPRING_DATA_REDIS_HOST=127.0.0.1 - SPRING_DATA_REDIS_PORT=6379 volumes: - ./logs:/var/log/spring - depends_on: - - redis - - alloy: - image: grafana/alloy:latest - container_name: alloy - ports: - - "12345:12345" - volumes: - - ./logs:/var/log/spring - - ./docs/infra-config/config.alloy:/etc/alloy/config.alloy:ro - environment: - - ALLOY_ENV=production + restart: always From 5788b2bd59051e29c1bb5cb76d67b7260d97a668 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:26:42 +0900 Subject: [PATCH 22/31] =?UTF-8?q?fix:=20S3=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: s3 이름 불일치 문제 해결 * fix: s3와의 연동된 cloudfront URL로 수정 --- src/main/resources/secret | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/secret b/src/main/resources/secret index 812db022a..29524e2d6 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 812db022ab3f2d83b43cfedae3ba57518d334a9c +Subproject commit 29524e2d6dad2042400de0370a11893029aacff2 From 513b59e1c98f173a8a31ec4a92c6f73622b8f618 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:31:32 +0900 Subject: [PATCH 23/31] =?UTF-8?q?refactor:=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EB=93=9C=20=EC=9D=B8=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?(#596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/infra-config/config.alloy | 37 ----------------------- docs/infra-config/nginx.dev.conf | 50 ------------------------------- docs/infra-config/nginx.prod.conf | 41 ------------------------- 3 files changed, 128 deletions(-) delete mode 100644 docs/infra-config/config.alloy delete mode 100644 docs/infra-config/nginx.dev.conf delete mode 100644 docs/infra-config/nginx.prod.conf diff --git a/docs/infra-config/config.alloy b/docs/infra-config/config.alloy deleted file mode 100644 index bd3aedf9a..000000000 --- a/docs/infra-config/config.alloy +++ /dev/null @@ -1,37 +0,0 @@ -livedebugging { - enabled = true -} - -logging { - level = "info" - format = "logfmt" -} - -local.file_match "spring_logs" { - path_targets = [{ __path__ = "/var/log/spring/*.log" }] // 서비스 로그 파일 경로 -} - -loki.source.file "spring_source" { - targets = local.file_match.spring_logs.targets // 위에서 정의한 로그 파일 경로 사용 - forward_to = [loki.process.spring_labels.receiver] // 읽은 로그를 처리 단계로 전달 -} - -loki.process "spring_labels" { - forward_to = [loki.write.grafana_loki.receiver] // 처리된 로그를 Loki로 전송 - - stage.static_labels { - values = { - service = "backend", - env = sys.env("ALLOY_ENV"), - } - } -} - -loki.write "grafana_loki" { - endpoint { - url = "http://monitor.solid-connection.com:3100/loki/api/v1/push" - tenant_id = "fake" // Loki 테넌트 ID (싱글 테넌시이기에 fake로 설정) - batch_wait = "1s" // 로그 배치 전송 대기 시간 - batch_size = "1MB" // 로그 배치 크기 - } -} diff --git a/docs/infra-config/nginx.dev.conf b/docs/infra-config/nginx.dev.conf deleted file mode 100644 index ae3a35a47..000000000 --- a/docs/infra-config/nginx.dev.conf +++ /dev/null @@ -1,50 +0,0 @@ -map $http_upgrade $connection_upgrade { - default upgrade; - '' keep-alive; -} - -server { - listen 80; - server_name api.stage.solid-connection.com; - -# http를 사용하는 경우 주석 해제 -# location / { -# proxy_pass http://solid-connection-server:8080; -# proxy_set_header Host $host; -# proxy_set_header X-Real-IP $remote_addr; -# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -# proxy_set_header X-Forwarded-Proto $scheme; -# } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl; - server_name api.stage.solid-connection.com; - - ssl_certificate /etc/letsencrypt/live/api.stage.solid-connection.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/api.stage.solid-connection.com/privkey.pem; - client_max_body_size 10M; - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers on; # 클라이언트 보다 서버의 암호화 알고리즘을 우선하도록 설정 - ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"; - ssl_session_cache shared:SSL:10m; # SSL 세션 캐시 설정 - ssl_session_timeout 10m; - ssl_stapling on; # OCSP 스테이플링 활성화 - ssl_stapling_verify on; - - location / { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } -} diff --git a/docs/infra-config/nginx.prod.conf b/docs/infra-config/nginx.prod.conf deleted file mode 100644 index ad0d81792..000000000 --- a/docs/infra-config/nginx.prod.conf +++ /dev/null @@ -1,41 +0,0 @@ -map $http_upgrade $connection_upgrade { - default upgrade; - '' keep-alive; -} - -server { - listen 80; - server_name api.solid-connection.com; - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl; - server_name api.solid-connection.com; - - ssl_certificate /etc/letsencrypt/live/api.solid-connection.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/api.solid-connection.com/privkey.pem; - client_max_body_size 10M; - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers on; # 클라이언트 보다 서버의 암호화 알고리즘을 우선하도록 설정 - ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"; - ssl_session_cache shared:SSL:10m; # SSL 세션 캐시 설정 - ssl_session_timeout 10m; - ssl_stapling on; # OCSP 스테이플링 활성화 - ssl_stapling_verify on; - - location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } -} From d1cc8c3306f4ef6a0c34d6c237803a62c9c76619 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:03:31 +0900 Subject: [PATCH 24/31] =?UTF-8?q?test:=20flyway=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=EB=A5=BC=20=EA=B2=80=EC=A6=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: flyway 스크립트를 검증하는 테스트 코드 작성 * fix: DirtiesContext 어노테이션을 통해 기존 컨텍스트를 폐기하도록 - 새로운 MySQL 환경에서 마이그레이션이 이루어지도록 수정 * fix: flyway 검증용의 별도의 MySQL 컨테이너를 사용하도록 * chore: 테스트 의도를 쉽게 이해할 수 있도록 주석 추가 * chore: 명시적으로 컨테이너를 시작하도록 - 또한 MySQLTestContainer 코드와 유사한 컨벤션을 가지도록 수정 --- .../database/FlywayMigrationTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/test/java/com/example/solidconnection/database/FlywayMigrationTest.java diff --git a/src/test/java/com/example/solidconnection/database/FlywayMigrationTest.java b/src/test/java/com/example/solidconnection/database/FlywayMigrationTest.java new file mode 100644 index 000000000..2649716f1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/database/FlywayMigrationTest.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.database; + +import com.example.solidconnection.support.RedisTestContainer; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.containers.MySQLContainer; + +@SpringBootTest +@ContextConfiguration(initializers = {RedisTestContainer.class, FlywayMigrationTest.FlywayMySQLInitializer.class}) +@TestPropertySource(properties = { + "spring.flyway.enabled=true", + "spring.flyway.baseline-on-migrate=true", + "spring.jpa.hibernate.ddl-auto=validate" +}) +class FlywayMigrationTest { + + private static final MySQLContainer CONTAINER = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("flyway_test") + .withUsername("flyway_user") + .withPassword("flyway_password"); + + static { + CONTAINER.start(); + } + + static class FlywayMySQLInitializer implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + TestPropertyValues.of( + "spring.datasource.url=" + CONTAINER.getJdbcUrl(), + "spring.datasource.username=" + CONTAINER.getUsername(), + "spring.datasource.password=" + CONTAINER.getPassword() + ).applyTo(applicationContext.getEnvironment()); + } + } + + @Test + void flyway_스크립트가_정상적으로_수행되는지_확인한다() { + // Spring Boot 컨텍스트가 정상적으로 시작되면 + // Flyway 마이그레이션과 ddl-auto=validate 검증이 성공한 것 + } +} From 7b79071f022e9c790f63ba1bb9e614f73cb25046 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 5 Jan 2026 19:24:56 +0900 Subject: [PATCH 25/31] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20deprecated=20=EC=97=AC=EB=B6=80=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=98=EB=8F=84=EB=A1=9D=20(#599)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../community/comment/dto/PostFindCommentResponse.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java index 16446f3ee..172ff23ee 100644 --- a/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java @@ -10,6 +10,7 @@ public record PostFindCommentResponse( Long parentId, String content, Boolean isOwner, + Boolean isDeleted, ZonedDateTime createdAt, ZonedDateTime updatedAt, PostFindSiteUserResponse postFindSiteUserResponse @@ -21,6 +22,7 @@ public static PostFindCommentResponse from(Boolean isOwner, Comment comment, Sit getParentCommentId(comment), getDisplayContent(comment), isOwner, + comment.isDeleted(), comment.getCreatedAt(), comment.getUpdatedAt(), getDisplaySiteUserResponse(comment, siteUser) From b3a94da230c434d17bf02edcc617a0ee6fb4fd0d Mon Sep 17 00:00:00 2001 From: hyungjun <115551339+sukangpunch@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:25:28 +0900 Subject: [PATCH 26/31] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=EC=9D=98=20?= =?UTF-8?q?=EB=A9=98=ED=86=A0=20=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유저의 멘토 지원 이력 조회 기능 추가 * refactor: 매개변수 타입 통일 * refactor: long 타입을 Long 으로 수정 * test: 멘토 지원서 이력 조회 테스트 추가 * test: MentorApplicationFixtureBuilder 에 rejectedReason 필드 및 빌더 메서드 추가 * refactor: 리뷰 사항 적용 * test: 멘토 지원서 이력 조회 에서 user와 university 재사용 * refactor: 긴 uri 를 짧게 수정 * refactor: 서브모듈 해시값 되돌리기 * refactor: 개행 지우기 * refactor: applicationOrder 자료형을 long 으로 수정 * fix: applicationOrder 를 int 자료형으로 처리하도록 복구 - 순서를 나타내고, 해당 값이 21억을 넘길 수 없다 판단하여 더 적합한 int 자료형으로 복구 * test: long type 을 기대하던 테스트 에러 해결 --- .../AdminMentorApplicationController.java | 10 ++ .../dto/MentorApplicationHistoryResponse.java | 14 +++ .../AdminMentorApplicationService.java | 30 ++++- .../MentorApplicationRepository.java | 4 + .../AdminMentorApplicationServiceTest.java | 104 ++++++++++++++++++ .../fixture/MentorApplicationFixture.java | 2 + .../MentorApplicationFixtureBuilder.java | 9 ++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/solidconnection/admin/dto/MentorApplicationHistoryResponse.java diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java index b8fe69742..c30a9620f 100644 --- a/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminMentorApplicationController.java @@ -2,12 +2,14 @@ import com.example.solidconnection.admin.dto.MentorApplicationAssignUniversityRequest; import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; +import com.example.solidconnection.admin.dto.MentorApplicationHistoryResponse; import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; import com.example.solidconnection.admin.service.AdminMentorApplicationService; import com.example.solidconnection.common.response.PageResponse; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -73,4 +75,12 @@ public ResponseEntity assignUniversity( adminMentorApplicationService.assignUniversity(mentorApplicationId, universityId); return ResponseEntity.ok().build(); } + + @GetMapping("/{site-user-id}/history") + public ResponseEntity> getMentorApplicationHistory( + @PathVariable("site-user-id") Long siteUserId + ){ + List response = adminMentorApplicationService.findMentorApplicationHistory(siteUserId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationHistoryResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationHistoryResponse.java new file mode 100644 index 000000000..46f7493dc --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationHistoryResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import java.time.ZonedDateTime; + +public record MentorApplicationHistoryResponse( + long id, + MentorApplicationStatus mentorApplicationStatus, + String rejectedReason, + ZonedDateTime createdAt, + int applicationOrder +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java index d22d9af7f..86d8a0398 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -1,8 +1,10 @@ package com.example.solidconnection.admin.service; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; +import com.example.solidconnection.admin.dto.MentorApplicationHistoryResponse; import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; @@ -10,8 +12,12 @@ import com.example.solidconnection.mentor.domain.MentorApplication; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import com.example.solidconnection.mentor.repository.MentorApplicationRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.domain.University; import com.example.solidconnection.university.repository.UniversityRepository; +import java.util.List; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -24,6 +30,7 @@ public class AdminMentorApplicationService { private final MentorApplicationRepository mentorApplicationRepository; private final UniversityRepository universityRepository; + private final SiteUserRepository siteUserRepository; @Transactional(readOnly = true) public Page searchMentorApplications( @@ -43,7 +50,7 @@ public void approveMentorApplication(Long mentorApplicationId) { @Transactional public void rejectMentorApplication( - long mentorApplicationId, + Long mentorApplicationId, MentorApplicationRejectRequest request ) { MentorApplication mentorApplication = mentorApplicationRepository.findById(mentorApplicationId) @@ -79,4 +86,25 @@ public void assignUniversity( mentorApplication.assignUniversity(university.getId()); } + + @Transactional(readOnly = true) + public List findMentorApplicationHistory(Long siteUserId) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + long totalCount = mentorApplicationRepository.countBySiteUserId(siteUser.getId()); + List mentorApplications = mentorApplicationRepository.findTop5BySiteUserIdOrderByCreatedAtDesc(siteUser.getId()); + + return IntStream.range(0, mentorApplications.size()) + .mapToObj(index -> { + MentorApplication app = mentorApplications.get(index); + return new MentorApplicationHistoryResponse( + app.getId(), + app.getMentorApplicationStatus(), + app.getRejectedReason(), + app.getCreatedAt(), + (int) totalCount - index + ); + }).toList(); + } } diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java index 144b5461c..61339819a 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java @@ -14,4 +14,8 @@ public interface MentorApplicationRepository extends JpaRepository findBySiteUserIdAndMentorApplicationStatus(long siteUserId, MentorApplicationStatus mentorApplicationStatus); long countByMentorApplicationStatus(MentorApplicationStatus mentorApplicationStatus); + + List findTop5BySiteUserIdOrderByCreatedAtDesc(long siteUserId); + + long countBySiteUserId(long siteUserId); } diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java index ae40cb5d7..0c133165a 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java @@ -5,11 +5,13 @@ import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_OTHER_STATUS; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_UNIVERSITY_NOT_SELECTED; import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; import com.example.solidconnection.admin.dto.MentorApplicationCountResponse; +import com.example.solidconnection.admin.dto.MentorApplicationHistoryResponse; import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; @@ -63,6 +65,9 @@ class AdminMentorApplicationServiceTest { private MentorApplication mentorApplication7; private MentorApplication mentorApplication8; + private SiteUser user; + private University university; + @BeforeEach void setUp() { SiteUser user1 = siteUserFixture.사용자(1, "test1"); @@ -84,6 +89,9 @@ void setUp() { mentorApplication6 = mentorApplicationFixture.거절된_멘토신청(user6.getId(), UniversitySelectType.CATALOG, university2.getId()); mentorApplication7 = mentorApplicationFixture.대기중_멘토신청(user7.getId(), UniversitySelectType.OTHER, null); mentorApplication8 = mentorApplicationFixture.거절된_멘토신청(user8.getId(), UniversitySelectType.OTHER, null); + + user = siteUserFixture.사용자(9, "test9"); + university = universityFixture.메이지_대학(); } @Nested @@ -509,4 +517,100 @@ class 멘토_지원서에_대학_매핑 { .hasMessage(UNIVERSITY_NOT_FOUND.getMessage()); } } + + @Nested + class 멘토_지원서_이력_조회 { + + @Test + void 사용자의_멘토_지원서_이력을_최신_생성_내림차순으로_조회한다() { + // given + MentorApplication app1 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app2 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app3 = mentorApplicationFixture.승인된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + + // when + List response = adminMentorApplicationService.findMentorApplicationHistory(user.getId()); + + // then + assertAll( + () -> assertThat(response).hasSize(3), + () -> assertThat(response) + .extracting(MentorApplicationHistoryResponse::id) + .containsExactly(app3.getId(), app2.getId(), app1.getId()), + () -> assertThat(response) + .extracting(MentorApplicationHistoryResponse::applicationOrder) + .containsExactly(3,2,1) + ); + } + + @Test + void 지원서가_5개를_초과하면_최신_5개만_최신_생성_내림차순으로_조회한다() { + // given + MentorApplication app1 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app2 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app3 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app4 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app5 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app6 = mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + MentorApplication app7 = mentorApplicationFixture.승인된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + + // when + List response = adminMentorApplicationService.findMentorApplicationHistory(user.getId()); + + // then + assertAll( + () -> assertThat(response).hasSize(5), + () -> assertThat(response) + .extracting(MentorApplicationHistoryResponse::id) + .containsExactly(app7.getId(), app6.getId(), app5.getId(), app4.getId(), app3.getId()), + () -> assertThat(response) + .extracting(MentorApplicationHistoryResponse::applicationOrder) + .containsExactly(7,6,5,4,3) + ); + } + + @Test + void 지원서_이력이_없으면_빈_목록을_반환한다() { + // given + long withoutApplicationUserId = user.getId(); + + // when + List response = adminMentorApplicationService.findMentorApplicationHistory(withoutApplicationUserId); + + // then + assertThat(response).isEmpty(); + } + + @Test + void 응답에_지원서_상태와_거절_사유가_포함된다() { + // given + mentorApplicationFixture.거절된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + mentorApplicationFixture.승인된_멘토신청(user.getId(), UniversitySelectType.CATALOG, university.getId()); + + // when + List response = adminMentorApplicationService.findMentorApplicationHistory(user.getId()); + + // then + assertAll( + () -> assertThat(response).hasSize(2), + () -> assertThat(response.get(0).mentorApplicationStatus()).isEqualTo(MentorApplicationStatus.APPROVED), + () -> assertThat(response.get(0).rejectedReason()).isNull(), + () -> assertThat(response.get(0).applicationOrder()).isEqualTo(2), + () -> assertThat(response.get(1).mentorApplicationStatus()).isEqualTo(MentorApplicationStatus.REJECTED), + () -> assertThat(response.get(1).rejectedReason()).isNotNull(), + () -> assertThat(response.get(1).applicationOrder()).isEqualTo(1) + ); + } + + @Test + void 존재하지_않는_사용자_이력을_조회하면_예외_응답을_반환한다() { + // given + long nonExistentUserId = 99999L; + + // when & then + assertThatCode(() -> adminMentorApplicationService.findMentorApplicationHistory(nonExistentUserId)) + .isInstanceOf(CustomException.class) + .hasMessage(USER_NOT_FOUND.getMessage()); + } + } } diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java index 0baf62e2f..6a7153757 100644 --- a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixture.java @@ -18,6 +18,7 @@ public class MentorApplicationFixture { private static final String DEFAULT_COUNTRY_CODE = "US"; private static final String DEFAULT_PROOF_URL = "/mentor-proof.pdf"; private static final ExchangeStatus DEFAULT_EXCHANGE_STATUS = ExchangeStatus.AFTER_EXCHANGE; + private static final String REJECTED_REASON = "pdf 파일 안열림"; public MentorApplication 대기중_멘토신청( long siteUserId, @@ -64,6 +65,7 @@ public class MentorApplicationFixture { .universitySelectType(selectType) .mentorProofUrl(DEFAULT_PROOF_URL) .termId(termFixture.현재_학기("2025-1").getId()) + .rejectedReason(REJECTED_REASON) .exchangeStatus(DEFAULT_EXCHANGE_STATUS) .mentorApplicationStatus(MentorApplicationStatus.REJECTED) .create(); diff --git a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java index fd2a74ff6..93e5e24bc 100644 --- a/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/mentor/fixture/MentorApplicationFixtureBuilder.java @@ -21,6 +21,7 @@ public class MentorApplicationFixtureBuilder { private UniversitySelectType universitySelectType = UniversitySelectType.OTHER; private String mentorProofUrl = "/mentor-proof.pdf"; private long termId; + private String rejectedReason = null; private ExchangeStatus exchangeStatus = ExchangeStatus.AFTER_EXCHANGE; private MentorApplicationStatus mentorApplicationStatus = MentorApplicationStatus.PENDING; @@ -58,6 +59,11 @@ public MentorApplicationFixtureBuilder termId(long termId) { return this; } + public MentorApplicationFixtureBuilder rejectedReason(String rejectedReason) { + this.rejectedReason = rejectedReason; + return this; + } + public MentorApplicationFixtureBuilder exchangeStatus(ExchangeStatus exchangeStatus) { this.exchangeStatus = exchangeStatus; return this; @@ -79,6 +85,9 @@ public MentorApplication create() { exchangeStatus ); ReflectionTestUtils.setField(mentorApplication, "mentorApplicationStatus", mentorApplicationStatus); + if(rejectedReason != null) { + ReflectionTestUtils.setField(mentorApplication, "rejectedReason", rejectedReason); + } return mentorApplicationRepository.save(mentorApplication); } } From 48d0f484a29816823b4ec0d62339942dcc5b8807 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:52:05 +0900 Subject: [PATCH 27/31] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=ED=95=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=EB=AC=BC=EB=A6=AC?= =?UTF-8?q?=EC=A0=81=20=EC=82=AD=EC=A0=9C=EA=B0=80=20=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=ED=95=9C=EB=8B=A4=20(#574)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: FK에 ON DELETE CASCADE 옵션 추가 * refactor: 삭제 메서드로 사용자 연관 데이터를 삭제하도록 --- .../repository/ApplicationRepository.java | 2 + .../repository/ChatParticipantRepository.java | 8 ++ .../repository/ChatReadStatusRepository.java | 3 + .../comment/repository/CommentRepository.java | 2 + .../post/repository/PostLikeRepository.java | 2 + .../post/repository/PostRepository.java | 2 + .../MentorApplicationRepository.java | 2 + .../mentor/repository/MentorRepository.java | 2 + .../repository/MentoringRepository.java | 2 + .../news/repository/LikedNewsRepository.java | 2 + .../news/repository/NewsRepository.java | 2 + .../report/repository/ReportRepository.java | 2 + .../scheduler/UserRemovalScheduler.java | 75 ++++++++++++++++++- .../score/repository/GpaScoreRepository.java | 2 + .../LanguageTestScoreRepository.java | 2 + .../repository/UserBlockRepository.java | 2 + .../LikedUnivApplyInfoRepository.java | 2 + 17 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java index 226e0f2b0..10bf6c04e 100644 --- a/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java @@ -40,4 +40,6 @@ default Application getApplicationBySiteUserIdAndTermId(long siteUserId, long te return findBySiteUserIdAndTermId(siteUserId, termId) .orElseThrow(() -> new CustomException(APPLICATION_NOT_FOUND)); } + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java index 4bce2d08c..19be659f6 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java @@ -1,12 +1,20 @@ package com.example.solidconnection.chat.repository; import com.example.solidconnection.chat.domain.ChatParticipant; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ChatParticipantRepository extends JpaRepository { boolean existsByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId); Optional findByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId); + + void deleteAllBySiteUserId(long siteUserId); + + @Query("SELECT cp.id FROM ChatParticipant cp WHERE cp.siteUserId = :siteUserId") + List findAllIdsBySiteUserId(@Param("siteUserId") long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java index 5ff82a75b..b6f0fec06 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java @@ -1,6 +1,7 @@ package com.example.solidconnection.chat.repository; import com.example.solidconnection.chat.domain.ChatReadStatus; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -15,4 +16,6 @@ INSERT INTO chat_read_status (chat_room_id, chat_participant_id, created_at, upd ON DUPLICATE KEY UPDATE updated_at = NOW(6) """, nativeQuery = true) void upsertReadStatus(@Param("chatRoomId") long chatRoomId, @Param("chatParticipantId") long chatParticipantId); + + void deleteAllByChatParticipantIdIn(List chatParticipantIds); } diff --git a/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java index 4291eb57c..8464299e5 100644 --- a/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java @@ -42,4 +42,6 @@ default Comment getById(Long id) { return findById(id) .orElseThrow(() -> new CustomException(INVALID_COMMENT_ID)); } + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java index 4fa3d3e72..a7791a55e 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java @@ -16,4 +16,6 @@ default PostLike getByPostAndSiteUserId(Post post, long siteUserId) { return findPostLikeByPostAndSiteUserId(post, siteUserId) .orElseThrow(() -> new CustomException(INVALID_POST_LIKE)); } + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index cca590270..285bcb151 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -59,4 +59,6 @@ default Post getById(Long id) { return findById(id) .orElseThrow(() -> new CustomException(INVALID_POST_ID)); } + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java index 61339819a..1da033d45 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorApplicationRepository.java @@ -11,6 +11,8 @@ public interface MentorApplicationRepository extends JpaRepository mentorApplicationStatuses); + void deleteAllBySiteUserId(long siteUserId); + Optional findBySiteUserIdAndMentorApplicationStatus(long siteUserId, MentorApplicationStatus mentorApplicationStatus); long countByMentorApplicationStatus(MentorApplicationStatus mentorApplicationStatus); diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java index 85dbbc0bf..672f9325e 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java @@ -27,4 +27,6 @@ public interface MentorRepository extends JpaRepository { Slice findAllByRegion(@Param("region") Region region, Pageable pageable); List findAllBySiteUserIdIn(Set siteUserIds); + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java index 16230b74b..2d072be07 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java @@ -39,4 +39,6 @@ public interface MentoringRepository extends JpaRepository { @Query("SELECT m FROM Mentoring m WHERE m.menteeId = :menteeId AND m.verifyStatus = :verifyStatus") Slice findApprovedMentoringsByMenteeId(long menteeId, @Param("verifyStatus") VerifyStatus verifyStatus, Pageable pageable); + + void deleteAllByMenteeId(long menteeId); } diff --git a/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java index 26e3d8e3c..0bbd60930 100644 --- a/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java +++ b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java @@ -9,4 +9,6 @@ public interface LikedNewsRepository extends JpaRepository { boolean existsByNewsIdAndSiteUserId(long newsId, long siteUserId); Optional findByNewsIdAndSiteUserId(long newsId, long siteUserId); + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java index 0d3ccf3e9..566d8feae 100644 --- a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java +++ b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java @@ -8,4 +8,6 @@ public interface NewsRepository extends JpaRepository, NewsCustomRepository { List findAllBySiteUserIdOrderByUpdatedAtDesc(long siteUserId); + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java index c32d3cd5f..91e94da8d 100644 --- a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -7,4 +7,6 @@ public interface ReportRepository extends JpaRepository { boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId); + + void deleteAllByReporterId(long reporterId); } diff --git a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java index da39cf2e9..4428e622a 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java @@ -1,12 +1,32 @@ package com.example.solidconnection.scheduler; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.chat.repository.ChatParticipantRepository; +import com.example.solidconnection.chat.repository.ChatReadStatusRepository; +import com.example.solidconnection.community.comment.repository.CommentRepository; +import com.example.solidconnection.community.post.repository.PostLikeRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.location.country.repository.InterestedCountryRepository; +import com.example.solidconnection.location.region.repository.InterestedRegionRepository; +import com.example.solidconnection.mentor.repository.MentorApplicationRepository; +import com.example.solidconnection.mentor.repository.MentorRepository; +import com.example.solidconnection.mentor.repository.MentoringRepository; +import com.example.solidconnection.news.repository.LikedNewsRepository; +import com.example.solidconnection.news.repository.NewsRepository; +import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBlockRepository; +import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -16,14 +36,67 @@ public class UserRemovalScheduler { public static final int ACCOUNT_RECOVER_DURATION = 30; private final SiteUserRepository siteUserRepository; + private final InterestedCountryRepository interestedCountryRepository; + private final InterestedRegionRepository interestedRegionRepository; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; + private final ApplicationRepository applicationRepository; + private final GpaScoreRepository gpaScoreRepository; + private final LanguageTestScoreRepository languageTestScoreRepository; + private final MentorRepository mentorRepository; + private final MentoringRepository mentoringRepository; + private final NewsRepository newsRepository; + private final LikedNewsRepository likedNewsRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final ChatReadStatusRepository chatReadStatusRepository; + private final ReportRepository reportRepository; + private final UserBlockRepository userBlockRepository; + private final MentorApplicationRepository mentorApplicationRepository; + private final S3Service s3Service; /* * 탈퇴 후 계정 복구 기한까지 방문하지 않은 사용자를 삭제한다. * */ @Scheduled(cron = EVERY_MIDNIGHT) + @Transactional public void scheduledUserRemoval() { LocalDate cutoffDate = LocalDate.now().minusDays(ACCOUNT_RECOVER_DURATION); List usersToRemove = siteUserRepository.findUsersToBeRemoved(cutoffDate); - siteUserRepository.deleteAll(usersToRemove); + + usersToRemove.forEach(this::deleteUserAndRelatedData); + } + + private void deleteUserAndRelatedData(SiteUser user) { + long siteUserId = user.getId(); + + likedNewsRepository.deleteAllBySiteUserId(siteUserId); + newsRepository.deleteAllBySiteUserId(siteUserId); + + postLikeRepository.deleteAllBySiteUserId(siteUserId); + commentRepository.deleteAllBySiteUserId(siteUserId); + postRepository.deleteAllBySiteUserId(siteUserId); + + mentoringRepository.deleteAllByMenteeId(siteUserId); + mentorRepository.deleteAllBySiteUserId(siteUserId); + mentorApplicationRepository.deleteAllBySiteUserId(siteUserId); + + List chatParticipantIds = chatParticipantRepository.findAllIdsBySiteUserId(siteUserId); + chatReadStatusRepository.deleteAllByChatParticipantIdIn(chatParticipantIds); + chatParticipantRepository.deleteAllBySiteUserId(siteUserId); + reportRepository.deleteAllByReporterId(siteUserId); + userBlockRepository.deleteAllByBlockerIdOrBlockedId(siteUserId, siteUserId); + + applicationRepository.deleteAllBySiteUserId(siteUserId); + gpaScoreRepository.deleteAllBySiteUserId(siteUserId); + languageTestScoreRepository.deleteAllBySiteUserId(siteUserId); + likedUnivApplyInfoRepository.deleteAllBySiteUserId(siteUserId); + interestedCountryRepository.deleteAllBySiteUserId(siteUserId); + interestedRegionRepository.deleteAllBySiteUserId(siteUserId); + + s3Service.deleteExProfile(siteUserId); + + siteUserRepository.delete(user); } } diff --git a/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java b/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java index 207e36234..c5ab3094c 100644 --- a/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java +++ b/src/main/java/com/example/solidconnection/score/repository/GpaScoreRepository.java @@ -13,4 +13,6 @@ public interface GpaScoreRepository extends JpaRepository, GpaSc Optional findGpaScoreBySiteUserIdAndId(long siteUserId, Long id); List findBySiteUserId(long siteUserId); + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java b/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java index 40fe50106..de66cbcc2 100644 --- a/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java +++ b/src/main/java/com/example/solidconnection/score/repository/LanguageTestScoreRepository.java @@ -14,4 +14,6 @@ public interface LanguageTestScoreRepository extends JpaRepository findLanguageTestScoreBySiteUserIdAndId(long siteUserId, Long id); List findBySiteUserId(long siteUserId); + + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java index 28a88874a..01bc06445 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/UserBlockRepository.java @@ -24,4 +24,6 @@ public interface UserBlockRepository extends JpaRepository { WHERE ub.blockerId = :blockerId """) Slice findBlockedUsersWithNickname(@Param("blockerId") long blockerId, Pageable pageable); + + void deleteAllByBlockerIdOrBlockedId(long blockerId, long blockedId); } diff --git a/src/main/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepository.java b/src/main/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepository.java index 0dc8255a6..684703b58 100644 --- a/src/main/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/LikedUnivApplyInfoRepository.java @@ -25,4 +25,6 @@ public interface LikedUnivApplyInfoRepository extends JpaRepository findUnivApplyInfosBySiteUserId(@Param("siteUserId") long siteUserId); boolean existsBySiteUserIdAndUnivApplyInfoId(long siteUserId, long univApplyInfoId); + + void deleteAllBySiteUserId(long siteUserId); } From ddf29e292cf724e1756c1a7d00b847b84b33806a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9E=AC=ED=9D=AC?= Date: Fri, 16 Jan 2026 17:56:33 +0900 Subject: [PATCH 28/31] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EC=B0=A8=EB=8B=A8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 어드민 차단 기능 * test: 어드민 차단 기능 --- .../controller/AdminUserBanController.java | 42 +++ .../admin/dto/UserBanRequest.java | 11 + .../admin/service/AdminUserBanService.java | 113 ++++++++ .../auth/dto/SignUpRequest.java | 26 -- .../auth/service/signup/SignUpService.java | 4 +- .../chat/domain/ChatMessage.java | 5 + .../repository/ChatMessageRepository.java | 17 ++ .../common/config/web/WebMvcConfig.java | 9 + .../common/exception/ErrorCode.java | 6 + .../interceptor/BannedUserInterceptor.java | 37 +++ .../community/post/domain/Post.java | 4 + .../post/repository/PostRepository.java | 16 ++ .../solidconnection/report/domain/Report.java | 6 +- .../report/repository/ReportRepository.java | 2 + .../report/service/ReportService.java | 46 ++- .../siteuser/domain/SiteUser.java | 12 +- .../siteuser/domain/UserBan.java | 61 ++++ .../siteuser/domain/UserBanDuration.java | 14 + .../siteuser/domain/UserStatus.java | 7 + .../repository/SiteUserRepository.java | 6 + .../repository/UserBanRepository.java | 24 ++ .../migration/V40__create_user_ban_table.sql | 23 ++ ...dd_is_deleted_to_post_and_chat_message.sql | 3 + .../service/AdminUserBanServiceTest.java | 262 ++++++++++++++++++ .../BannedUserInterceptorTest.java | 155 +++++++++++ .../report/fixture/ReportFixture.java | 3 +- .../report/fixture/ReportFixtureBuilder.java | 7 + .../report/service/ReportServiceTest.java | 14 +- .../siteuser/fixture/SiteUserFixture.java | 31 +++ .../fixture/SiteUserFixtureBuilder.java | 10 +- .../siteuser/fixture/UserBanFixture.java | 37 +++ .../fixture/UserBanFixtureBuilder.java | 49 ++++ 32 files changed, 1025 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java create mode 100644 src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java create mode 100644 src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java create mode 100644 src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java create mode 100644 src/main/resources/db/migration/V40__create_user_ban_table.sql create mode 100644 src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql create mode 100644 src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java create mode 100644 src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java create mode 100644 src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java create mode 100644 src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java new file mode 100644 index 000000000..f0a699b13 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.admin.controller; + +import org.springframework.http.ResponseEntity; +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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.solidconnection.admin.dto.UserBanRequest; +import com.example.solidconnection.admin.service.AdminUserBanService; +import com.example.solidconnection.common.resolver.AuthorizedUser; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RequestMapping("/admin/users") +@RestController +public class AdminUserBanController { + private final AdminUserBanService adminUserBanService; + + @PostMapping("/{user-id}/ban") + public ResponseEntity banUser( + @AuthorizedUser long adminId, + @PathVariable(name = "user-id") long userId, + @Valid @RequestBody UserBanRequest request + ) { + adminUserBanService.banUser(userId, adminId, request); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/{user-id}/unban") + public ResponseEntity unbanUser( + @AuthorizedUser long adminId, + @PathVariable(name = "user-id") long userId + ) { + adminUserBanService.unbanUser(userId, adminId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java b/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java new file mode 100644 index 000000000..eaf57df20 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.siteuser.domain.UserBanDuration; + +import jakarta.validation.constraints.NotNull; + +public record UserBanRequest( + @NotNull(message = "차단 기간을 입력해주세요.") + UserBanDuration duration +) { +} diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java new file mode 100644 index 000000000..1f775acc8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java @@ -0,0 +1,113 @@ +package com.example.solidconnection.admin.service; + +import static java.time.ZoneOffset.UTC; + +import com.example.solidconnection.admin.dto.UserBanRequest; +import com.example.solidconnection.chat.repository.ChatMessageRepository; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserStatus; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBanRepository; +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class AdminUserBanService { + + private final UserBanRepository userBanRepository; + private final ReportRepository reportRepository; + private final SiteUserRepository siteUserRepository; + private final PostRepository postRepository; + private final ChatMessageRepository chatMessageRepository; + + @Transactional + public void banUser(long userId, long adminId, UserBanRequest request) { + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + validateNotAlreadyBanned(userId); + validateReportExists(userId); + + user.updateUserStatus(UserStatus.BANNED); + updateReportedContentIsDeleted(userId, true); + createUserBan(userId, adminId, request); + } + + private void validateNotAlreadyBanned(long userId) { + if (userBanRepository.existsByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC))) { + throw new CustomException(ErrorCode.ALREADY_BANNED_USER); + } + } + + private void validateReportExists(long userId) { + if (!reportRepository.existsByReportedId(userId)) { + throw new CustomException(ErrorCode.REPORT_NOT_FOUND); + } + } + + private void updateReportedContentIsDeleted(long userId, boolean isDeleted) { + postRepository.updateReportedPostsIsDeleted(userId, isDeleted); + chatMessageRepository.updateReportedChatMessagesIsDeleted(userId, isDeleted); + } + + private void createUserBan(long userId, long adminId, UserBanRequest request) { + ZonedDateTime now = ZonedDateTime.now(UTC); + ZonedDateTime expiredAt = now.plusDays(request.duration().getDays()); + UserBan userBan = new UserBan(userId, adminId, request.duration(), expiredAt); + userBanRepository.save(userBan); + } + + @Transactional + public void unbanUser(long userId, long adminId) { + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + UserBan userBan = findActiveBan(userId); + userBan.manuallyUnban(adminId); + + user.updateUserStatus(UserStatus.REPORTED); + updateReportedContentIsDeleted(userId, false); + } + + private UserBan findActiveBan(long userId) { + return userBanRepository + .findByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC)) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_BANNED_USER)); + } + + @Transactional + @Scheduled(cron = "0 0 0 * * *") + public void expireUserBans() { + try { + ZonedDateTime now = ZonedDateTime.now(UTC); + List expiredUserIds = userBanRepository.findExpiredBannedUserIds(now); + + if (expiredUserIds.isEmpty()) { + return; + } + + userBanRepository.bulkExpireUserBans(now); + siteUserRepository.bulkUpdateUserStatus(expiredUserIds, UserStatus.REPORTED); + bulkUpdateReportedContentIsDeleted(expiredUserIds); + log.info("Finished processing expired blocks:: userIds={}", expiredUserIds); + } catch (Exception e) { + log.error("Failed to process expired blocks", e); + } + } + + private void bulkUpdateReportedContentIsDeleted(List expiredUserIds) { + postRepository.bulkUpdateReportedPostsIsDeleted(expiredUserIds, false); + chatMessageRepository.bulkUpdateReportedChatMessagesIsDeleted(expiredUserIds, false); + } + +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index bafb9b4c8..81991fd90 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -1,9 +1,6 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.ExchangeStatus; -import com.example.solidconnection.siteuser.domain.Role; -import com.example.solidconnection.siteuser.domain.SiteUser; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import java.util.List; @@ -20,27 +17,4 @@ public record SignUpRequest( @NotBlank(message = "닉네임을 입력해주세요.") String nickname) { - - public SiteUser toOAuthSiteUser(String email, AuthType authType) { - return new SiteUser( - email, - this.nickname, - this.profileImageUrl, - this.exchangeStatus, - Role.MENTEE, - authType - ); - } - - public SiteUser toEmailSiteUser(String email, String encodedPassword) { - return new SiteUser( - email, - this.nickname, - this.profileImageUrl, - this.exchangeStatus, - Role.MENTEE, - AuthType.EMAIL, - encodedPassword - ); - } } diff --git a/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java index 86415d913..8f814be4a 100644 --- a/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java @@ -13,6 +13,7 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -56,7 +57,8 @@ public SignInResponse signUp(SignUpRequest signUpRequest) { signUpRequest.exchangeStatus(), Role.MENTEE, authType, - password + password, + UserStatus.ACTIVE )); // 관심 지역, 국가 저장 diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java index aa7369451..f2ec4d820 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -15,10 +15,12 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.Where; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Where(clause = "is_deleted = false") public class ChatMessage extends BaseEntity { @Id @@ -33,6 +35,9 @@ public class ChatMessage extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private ChatRoom chatRoom; + @Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false) + private boolean isDeleted = false; + @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true) private final List chatAttachments = new ArrayList<>(); diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java index e27e3e86d..ae81a3341 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -48,4 +49,20 @@ SELECT MAX(cm2.id) GROUP BY cm.chatRoom.id """) List countUnreadMessagesBatch(@Param("chatRoomIds") List chatRoomIds, @Param("userId") long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE chat_message cm SET cm.is_deleted = :isDeleted + WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT') + AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id = :siteUserId) + """, nativeQuery = true) + void updateReportedChatMessagesIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE chat_message cm SET cm.is_deleted = :isDeleted + WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT') + AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id IN :siteUserIds) + """, nativeQuery = true) + void bulkUpdateReportedChatMessagesIsDeleted(@Param("siteUserIds") List siteUserIds, @Param("isDeleted") boolean isDeleted); } diff --git a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java index 56bb288e8..47d70689d 100644 --- a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java @@ -1,11 +1,13 @@ package com.example.solidconnection.common.config.web; +import com.example.solidconnection.common.interceptor.BannedUserInterceptor; import com.example.solidconnection.common.resolver.AuthorizedUserResolver; import com.example.solidconnection.common.resolver.CustomPageableHandlerMethodArgumentResolver; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @@ -14,6 +16,7 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthorizedUserResolver authorizedUserResolver; private final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver; + private final BannedUserInterceptor bannedUserInterceptor; @Override public void addArgumentResolvers(List resolvers) { @@ -22,4 +25,10 @@ public void addArgumentResolvers(List resolvers) customPageableHandlerMethodArgumentResolver )); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(bannedUserInterceptor) + .addPathPatterns("/posts/**", "/comments/**", "/chats/**", "/boards/**"); + } } diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 07141979b..d00ce52b3 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -53,6 +53,7 @@ public enum ErrorCode { TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학기입니다."), CURRENT_TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "현재 학기를 찾을 수 없습니다."), MENTOR_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "멘토 지원서가 존재하지 않습니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "신고 내역이 존재하지 않습니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -150,6 +151,11 @@ public enum ErrorCode { // chat INVALID_CHAT_ROOM_STATE(HttpStatus.BAD_REQUEST.value(), "잘못된 채팅방 상태입니다."), + // ban + ALREADY_BANNED_USER(HttpStatus.CONFLICT.value(), "이미 차단된 사용자입니다."), + NOT_BANNED_USER(HttpStatus.BAD_REQUEST.value(), "차단되지 않은 사용자입니다."), + BANNED_USER_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "차단된 사용자는 커뮤니티 및 채팅을 이용할 수 없습니다."), + // database DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), diff --git a/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java b/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java new file mode 100644 index 000000000..de4d673fd --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.common.interceptor; + +import static com.example.solidconnection.common.exception.ErrorCode.BANNED_USER_ACCESS_DENIED; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class BannedUserInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler + ) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.getPrincipal() instanceof SiteUserDetails) { + SiteUserDetails userDetails = (SiteUserDetails) authentication.getPrincipal(); + SiteUser siteUser = userDetails.getSiteUser(); + + if (siteUser.getUserStatus() == UserStatus.BANNED) { + throw new CustomException(BANNED_USER_ACCESS_DENIED); + } + } + return true; + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/domain/Post.java b/src/main/java/com/example/solidconnection/community/post/domain/Post.java index 190861131..7b3f72745 100644 --- a/src/main/java/com/example/solidconnection/community/post/domain/Post.java +++ b/src/main/java/com/example/solidconnection/community/post/domain/Post.java @@ -18,11 +18,13 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.BatchSize; +import org.hibernate.annotations.Where; @Entity @Getter @NoArgsConstructor @EqualsAndHashCode(of = "id") +@Where(clause = "is_deleted = false") public class Post extends BaseEntity { @Id @@ -50,6 +52,8 @@ public class Post extends BaseEntity { @Column private long siteUserId; + @Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false) + private boolean isDeleted = false; @BatchSize(size = 20) @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index 285bcb151..a1e727d9c 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -50,6 +50,22 @@ AND p.siteUserId NOT IN ( """) void increaseViewCount(@Param("postId") Long postId, @Param("count") Long count); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE post p SET p.is_deleted = :isDeleted + WHERE p.site_user_id = :siteUserId + AND p.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'POST') + """, nativeQuery = true) + void updateReportedPostsIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE post p SET p.is_deleted = :isDeleted + WHERE p.site_user_id IN :siteUserIds + AND p.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'POST') + """, nativeQuery = true) + void bulkUpdateReportedPostsIsDeleted(@Param("siteUserIds") List siteUserIds, @Param("isDeleted") boolean isDeleted); + default Post getByIdUsingEntityGraph(Long id) { return findPostById(id) .orElseThrow(() -> new CustomException(INVALID_POST_ID)); diff --git a/src/main/java/com/example/solidconnection/report/domain/Report.java b/src/main/java/com/example/solidconnection/report/domain/Report.java index f6c17837b..d76d155f0 100644 --- a/src/main/java/com/example/solidconnection/report/domain/Report.java +++ b/src/main/java/com/example/solidconnection/report/domain/Report.java @@ -33,6 +33,9 @@ public class Report extends BaseEntity { @Column(name = "reporter_id") private long reporterId; + @Column(name = "reported_id") + private long reportedId; + @Column(name = "report_type") @Enumerated(value = EnumType.STRING) private ReportType reportType; @@ -44,9 +47,10 @@ public class Report extends BaseEntity { @Column(name = "target_id") private long targetId; - public Report(long reporterId, ReportType reportType, TargetType targetType, long targetId) { + public Report(long reporterId, long reportedId, ReportType reportType, TargetType targetType, long targetId) { this.reportType = reportType; this.reporterId = reporterId; + this.reportedId = reportedId; this.targetType = targetType; this.targetId = targetId; } diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java index 91e94da8d..b5f1832c2 100644 --- a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -8,5 +8,7 @@ public interface ReportRepository extends JpaRepository { boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId); + boolean existsByReportedId(long reportedId); + void deleteAllByReporterId(long reporterId); } diff --git a/src/main/java/com/example/solidconnection/report/service/ReportService.java b/src/main/java/com/example/solidconnection/report/service/ReportService.java index 205ca293d..9cfa1e389 100644 --- a/src/main/java/com/example/solidconnection/report/service/ReportService.java +++ b/src/main/java/com/example/solidconnection/report/service/ReportService.java @@ -1,13 +1,19 @@ package com.example.solidconnection.report.service; +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.repository.ChatMessageRepository; +import com.example.solidconnection.chat.repository.ChatParticipantRepository; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.report.domain.Report; import com.example.solidconnection.report.domain.TargetType; import com.example.solidconnection.report.dto.ReportRequest; import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -21,21 +27,28 @@ public class ReportService { private final SiteUserRepository siteUserRepository; private final PostRepository postRepository; private final ChatMessageRepository chatMessageRepository; + private final ChatParticipantRepository chatParticipantRepository; @Transactional public void createReport(long reporterId, ReportRequest request) { - validateReporterExists(reporterId); + long reportedId = findReportedId(request.targetType(), request.targetId()); + validateReporterAndReportedExists(reporterId, reportedId); validateTargetExists(request.targetType(), request.targetId()); validateFirstReportByUser(reporterId, request.targetType(), request.targetId()); + updateUserStatusToReported(reportedId); - Report report = new Report(reporterId, request.reportType(), request.targetType(), request.targetId()); + Report report = new Report(reporterId, reportedId, request.reportType(), request.targetType(), request.targetId()); reportRepository.save(report); } - private void validateReporterExists(long reporterId) { + private void validateReporterAndReportedExists(long reporterId, long reportedId) { if (!siteUserRepository.existsById(reporterId)) { throw new CustomException(ErrorCode.USER_NOT_FOUND); } + + if (!siteUserRepository.existsById(reportedId)) { + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } } private void validateTargetExists(TargetType targetType, long targetId) { @@ -54,4 +67,31 @@ private void validateFirstReportByUser(long reporterId, TargetType targetType, l throw new CustomException(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER); } } + + private long findReportedId(TargetType targetType, long targetId) { + return switch (targetType) { + case POST -> findPostAuthorId(targetId); + case CHAT -> findChatMessageSenderId(targetId); + }; + } + + private long findPostAuthorId(long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.REPORT_TARGET_NOT_FOUND)); + return post.getSiteUserId(); + } + + private long findChatMessageSenderId(long chatMessageId) { + ChatMessage chatMessage = chatMessageRepository.findById(chatMessageId) + .orElseThrow(() -> new CustomException(ErrorCode.REPORT_TARGET_NOT_FOUND)); + ChatParticipant chatParticipant = chatParticipantRepository.findById(chatMessage.getSenderId()) + .orElseThrow(() -> new CustomException(ErrorCode.CHAT_PARTICIPANT_NOT_FOUND)); + return chatParticipant.getSiteUserId(); + } + + private void updateUserStatusToReported(long userId) { + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + user.updateUserStatus(UserStatus.REPORTED); + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index 30afc423e..a82291d75 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -70,6 +70,10 @@ public class SiteUser extends BaseEntity { @Column(nullable = true) private String password; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private UserStatus userStatus = UserStatus.ACTIVE; + public SiteUser( String email, String nickname, @@ -107,7 +111,8 @@ public SiteUser( ExchangeStatus exchangeStatus, Role role, AuthType authType, - String password) { + String password, + UserStatus userStatus) { this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; @@ -115,9 +120,14 @@ public SiteUser( this.role = role; this.authType = authType; this.password = password; + this.userStatus = userStatus; } public void updatePassword(String newEncodedPassword) { this.password = newEncodedPassword; } + + public void updateUserStatus(UserStatus status) { + this.userStatus = status; + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java new file mode 100644 index 000000000..8dab3ea8a --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.siteuser.domain; + +import static java.time.ZoneOffset.UTC; + +import java.time.ZonedDateTime; +import com.example.solidconnection.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class UserBan extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "banned_user_id", nullable = false) + private Long bannedUserId; + + @Column(name = "banned_by", nullable = false) + private Long bannedBy; + + @Column(name = "duration", nullable = false) + @Enumerated(EnumType.STRING) + private UserBanDuration duration; + + @Column(name = "expired_at", nullable = false) + private ZonedDateTime expiredAt; + + @Column(name = "is_expired", nullable = false) + private boolean isExpired = false; + + @Column(name = "unbanned_by") + private Long unbannedBy; + + @Column(name = "unbanned_at") + private ZonedDateTime unbannedAt; + + public UserBan(Long bannedUserId, Long bannedBy, UserBanDuration duration, ZonedDateTime expiredAt) { + this.bannedUserId = bannedUserId; + this.bannedBy = bannedBy; + this.duration = duration; + this.expiredAt = expiredAt; + } + + public void manuallyUnban(Long adminId) { + this.isExpired = true; + this.unbannedBy = adminId; + this.unbannedAt = ZonedDateTime.now(UTC); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java new file mode 100644 index 000000000..2bbe64fe7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.siteuser.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum UserBanDuration { + ONE_DAY(1), + THREE_DAYS(3), + SEVEN_DAYS(7); + + private final int days; +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java new file mode 100644 index 000000000..50cbfb236 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.siteuser.domain; + +public enum UserStatus { + ACTIVE, + REPORTED, + BANNED +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java index 73422ba9f..123c1ab2b 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -2,10 +2,12 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,4 +23,8 @@ public interface SiteUserRepository extends JpaRepository { List findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate); List findAllByIdIn(List ids); + + @Modifying + @Query("UPDATE SiteUser u SET u.userStatus = :status WHERE u.id IN :userIds") + void bulkUpdateUserStatus(@Param("userIds") List userIds, @Param("status") UserStatus status); } diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java new file mode 100644 index 000000000..b897d29cf --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.siteuser.domain.UserBan; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserBanRepository extends JpaRepository { + + boolean existsByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(long bannedUserId, ZonedDateTime now); + + Optional findByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(long bannedUserId, ZonedDateTime now); + + @Query("SELECT ub.bannedUserId FROM UserBan ub WHERE ub.isExpired = false AND ub.expiredAt < :current") + List findExpiredBannedUserIds(@Param("current") ZonedDateTime current); + + @Modifying + @Query("UPDATE UserBan ub SET ub.isExpired = true WHERE ub.isExpired = false AND ub.expiredAt < :current") + void bulkExpireUserBans(@Param("current") ZonedDateTime current); +} diff --git a/src/main/resources/db/migration/V40__create_user_ban_table.sql b/src/main/resources/db/migration/V40__create_user_ban_table.sql new file mode 100644 index 000000000..4a695fe62 --- /dev/null +++ b/src/main/resources/db/migration/V40__create_user_ban_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE user_ban +( + id BIGINT NOT NULL AUTO_INCREMENT, + banned_user_id BIGINT NOT NULL, + banned_by BIGINT NOT NULL, + duration VARCHAR(30) NOT NULL, + expired_at DATETIME(6) NOT NULL, + is_expired TINYINT(1) NOT NULL DEFAULT 0, + unbanned_by BIGINT NULL, + unbanned_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_user_ban_banned_user_id FOREIGN KEY (banned_user_id) REFERENCES site_user (id), + CONSTRAINT fk_user_ban_banned_by_id FOREIGN KEY (banned_by) REFERENCES site_user (id), + CONSTRAINT fk_user_ban_unbanned_by_id FOREIGN KEY (unbanned_by) REFERENCES site_user (id) +); + +ALTER TABLE site_user + ADD COLUMN user_status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE'; + +ALTER TABLE report + ADD COLUMN reported_id BIGINT; diff --git a/src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql b/src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql new file mode 100644 index 000000000..5444af27c --- /dev/null +++ b/src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql @@ -0,0 +1,3 @@ +ALTER TABLE post ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE chat_message ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java new file mode 100644 index 000000000..60808ca3e --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java @@ -0,0 +1,262 @@ +package com.example.solidconnection.admin.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_BANNED_USER; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_BANNED_USER; +import static com.example.solidconnection.common.exception.ErrorCode.REPORT_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.admin.dto.UserBanRequest; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.community.board.fixture.BoardFixture; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.fixture.ReportFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserBanDuration; +import com.example.solidconnection.siteuser.domain.UserStatus; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.fixture.UserBanFixture; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBanRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("어드민 유저 차단 서비스 테스트") +class AdminUserBanServiceTest { + + @Autowired + private AdminUserBanService adminUserBanService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private UserBanRepository userBanRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private UserBanFixture userBanFixture; + + @Autowired + private ReportFixture reportFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private BoardFixture boardFixture; + + private SiteUser admin; + private SiteUser reportedUser; + private SiteUser reporter; + private Post reportedPost; + + @BeforeEach + void setUp() { + admin = siteUserFixture.관리자(); + reportedUser = siteUserFixture.신고된_사용자("신고된사용자"); + reporter = siteUserFixture.사용자(2, "신고자"); + reportedPost = postFixture.게시글( + "신고될 게시글", + "신고될 내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + reportedUser + ); + } + + @Nested + class 사용자_차단 { + + @Test + void 사용자를_차단한다() { + // given + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + + // when + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); + + // then + SiteUser bannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(bannedUser.getUserStatus()).isEqualTo(UserStatus.BANNED); + } + + @Test + void 이미_차단된_사용자일_경우_예외가_발생한다() { + // given + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); + + // when & then + assertThatCode(() -> adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_BANNED_USER.getMessage()); + } + + @Test + void 신고가_없는_사용자일_경우_예외가_발생한다() { + // given + SiteUser userWithoutReport = siteUserFixture.사용자(3, "신고없는유저"); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + + // when & then + assertThatCode(() -> adminUserBanService.banUser(userWithoutReport.getId(), admin.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(REPORT_NOT_FOUND.getMessage()); + } + } + + @Nested + class 사용자_차단_해제 { + + @Test + void 차단된_사용자를_수동으로_해제한다() { + // given + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); + + // when + adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); + + // then + SiteUser unbannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(unbannedUser.getUserStatus()).isEqualTo(UserStatus.REPORTED); + } + + @Test + void 차단_해제_정보가_올바르게_저장된다() { + // given + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); + ZonedDateTime beforeUnban = ZonedDateTime.now(); + + // when + adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); + + // then + List allBans = userBanRepository.findAll(); + UserBan unbannedUserBan = allBans.stream() + .filter(ban -> ban.getBannedUserId().equals(reportedUser.getId())) + .findFirst() + .orElseThrow(); + + assertAll( + () -> assertThat(unbannedUserBan.isExpired()).isTrue(), + () -> assertThat(unbannedUserBan.getUnbannedBy()).isEqualTo(admin.getId()), + () -> assertThat(unbannedUserBan.getUnbannedAt()).isAfter(beforeUnban) + ); + } + + @Test + void 차단되지_않은_사용자일_경우_예외가_발생한다() { + // given + SiteUser notBannedUser = siteUserFixture.사용자(3, "차단안된유저"); + + // when & then + assertThatCode(() -> adminUserBanService.unbanUser(notBannedUser.getId(), admin.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_BANNED_USER.getMessage()); + } + + @Test + void 만료된_차단일_경우_예외가_발생한다() { + // given + userBanFixture.만료된_차단(reportedUser.getId()); + + // when & then + assertThatCode(() -> adminUserBanService.unbanUser(reportedUser.getId(), admin.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_BANNED_USER.getMessage()); + } + } + + @Nested + class 만료된_차단_자동_해제 { + + @Test + void 만료된_차단들을_자동으로_해제한다() { + // given + SiteUser user1 = siteUserFixture.사용자(10, "유저1"); + SiteUser user2 = siteUserFixture.사용자(11, "유저2"); + + userBanFixture.만료된_차단(user1.getId()); + userBanFixture.만료된_차단(user2.getId()); + + user1.updateUserStatus(UserStatus.BANNED); + user2.updateUserStatus(UserStatus.BANNED); + + // when + adminUserBanService.expireUserBans(); + + // then + SiteUser unbannedUser1 = siteUserRepository.findById(user1.getId()).orElseThrow(); + SiteUser unbannedUser2 = siteUserRepository.findById(user2.getId()).orElseThrow(); + + assertAll( + () -> assertThat(unbannedUser1.getUserStatus()).isEqualTo(UserStatus.REPORTED), + () -> assertThat(unbannedUser2.getUserStatus()).isEqualTo(UserStatus.REPORTED) + ); + } + + @Test + void 만료되지_않은_차단은_유지된다() { + // given + Post reportedPost = postFixture.게시글( + "신고될 게시글", + "신고될 내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + reportedUser + ); + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), new UserBanRequest(UserBanDuration.SEVEN_DAYS)); + + // when + adminUserBanService.expireUserBans(); + + // then + SiteUser stillBannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(stillBannedUser.getUserStatus()).isEqualTo(UserStatus.BANNED); + } + + @Test + void 이미_수동으로_해제된_차단은_처리하지_않는다() { + // given + userBanFixture.수동_차단_해제(reportedUser.getId(), admin.getId()); + reportedUser.updateUserStatus(UserStatus.REPORTED); + + long beforeExpiredCount = userBanRepository.findAll().stream() + .filter(UserBan::isExpired) + .count(); + + // when + adminUserBanService.expireUserBans(); + + // then + long afterExpiredCount = userBanRepository.findAll().stream() + .filter(UserBan::isExpired) + .count(); + assertThat(afterExpiredCount).isEqualTo(beforeExpiredCount); + } + } +} diff --git a/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java b/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java new file mode 100644 index 000000000..d6337f55f --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java @@ -0,0 +1,155 @@ +package com.example.solidconnection.common.interceptor; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.solidconnection.community.board.fixture.BoardFixture; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; + +@TestContainerSpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("차단된 유저 인터셉터 테스트") +class BannedUserInterceptorTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private BoardFixture boardFixture; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void 차단된_사용자는_게시판_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/boards")) + .andExpect(status().isForbidden()); + } + + @Test + void 차단된_사용자는_게시글_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/posts/1")) + .andExpect(status().isForbidden()); + + mockMvc.perform(post("/posts")) + .andExpect(status().isForbidden()); + } + + @Test + void 차단된_사용자는_댓글_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "postId": 1, + "content": "테스트 댓글 내용", + "parentId": null + } + """)) + .andExpect(status().isForbidden()); + } + + @Test + void 차단된_사용자는_채팅_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/chats/rooms")) + .andExpect(status().isForbidden()); + } + + @Test + void 정상_사용자는_모든_경로_접근이_가능하다() throws Exception { + // given + SiteUser normalUser = siteUserFixture.사용자(1, "정상 유저1"); + Post post1 = postFixture.게시글( + "제목1", + "내용1", + false, + PostCategory.자유, + boardFixture.자유게시판(), + siteUserFixture.사용자(2, "정상 유저2") + ); + setAuthentication(normalUser); + + // when & then + mockMvc.perform(get("/boards")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/posts/" + post1.getId())) + .andExpect(status().isOk()); + + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "postId": 1, + "content": "테스트 댓글 내용", + "parentId": null + } + """)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/chats/rooms")) + .andExpect(status().isOk()); + } + + @Test + void 차단된_사용자도_다른_경로_접근은_가능하다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/my")) + .andExpect(status().isOk()); + } + + private void setAuthentication(SiteUser user) { + SiteUserDetails userDetails = new SiteUserDetails(user); + Authentication authentication = new TokenAuthentication("token", userDetails); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java index 91c837bf3..67a95e0e4 100644 --- a/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java @@ -11,9 +11,10 @@ public class ReportFixture { private final ReportFixtureBuilder reportFixtureBuilder; - public Report 신고(long reporterId, TargetType targetType, long targetId) { + public Report 신고(long reporterId, long reportedId, TargetType targetType, long targetId) { return reportFixtureBuilder.report() .reporterId(reporterId) + .reportedId(reportedId) .targetType(targetType) .targetId(targetId) .create(); diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java index 08d0b276c..0c7705dcf 100644 --- a/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java @@ -14,6 +14,7 @@ public class ReportFixtureBuilder { private final ReportRepository reportRepository; private long reporterId; + private long reportedId; private TargetType targetType; private long targetId; private ReportType reportType = ReportType.ADVERTISEMENT; @@ -27,6 +28,11 @@ public ReportFixtureBuilder reporterId(long reporterId) { return this; } + public ReportFixtureBuilder reportedId(long reportedId) { + this.reportedId = reportedId; + return this; + } + public ReportFixtureBuilder targetType(TargetType targetType) { this.targetType = targetType; return this; @@ -45,6 +51,7 @@ public ReportFixtureBuilder reasonType(ReportType reportType) { public Report create() { Report report = new Report( reporterId, + reportedId, reportType, targetType, targetId diff --git a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java index cdc9b875f..4a463ba35 100644 --- a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java +++ b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java @@ -4,8 +4,10 @@ import static org.assertj.core.api.Assertions.assertThatCode; import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.domain.ChatRoom; import com.example.solidconnection.chat.fixture.ChatMessageFixture; +import com.example.solidconnection.chat.fixture.ChatParticipantFixture; import com.example.solidconnection.chat.fixture.ChatRoomFixture; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; @@ -52,20 +54,26 @@ class ReportServiceTest { @Autowired private ChatRoomFixture chatRoomFixture; + @Autowired + private ChatParticipantFixture chatParticipantFixture; + @Autowired private ChatMessageFixture chatMessageFixture; private SiteUser siteUser; + private SiteUser reportedUser; private Post post; private ChatMessage chatMessage; @BeforeEach void setUp() { siteUser = siteUserFixture.사용자(); + reportedUser = siteUserFixture.신고된_사용자("신고된사용자"); Board board = boardFixture.자유게시판(); post = postFixture.게시글(board, siteUser); ChatRoom chatRoom = chatRoomFixture.채팅방(false); - chatMessage = chatMessageFixture.메시지("채팅", siteUser.getId(), chatRoom); + ChatParticipant chatParticipant = chatParticipantFixture.참여자(siteUser.getId(), chatRoom); + chatMessage = chatMessageFixture.메시지("채팅", chatParticipant.getId(), chatRoom); } @Nested @@ -100,7 +108,7 @@ class 포스트_신고 { @Test void 이미_신고한_경우_예외가_발생한다() { // given - reportFixture.신고(siteUser.getId(), TargetType.POST, post.getId()); + reportFixture.신고(siteUser.getId(), reportedUser.getId(), TargetType.POST, post.getId()); ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, post.getId()); // when & then @@ -142,7 +150,7 @@ class 채팅_신고 { @Test void 이미_신고한_경우_예외가_발생한다() { // given - reportFixture.신고(siteUser.getId(), TargetType.CHAT, chatMessage.getId()); + reportFixture.신고(siteUser.getId(), reportedUser.getId(), TargetType.CHAT, chatMessage.getId()); ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.CHAT, chatMessage.getId()); // when & then diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java index 9c2eb12bc..cdf48a024 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java @@ -3,6 +3,7 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -20,6 +21,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password("password123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -31,6 +33,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password("password123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -42,6 +45,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password("password123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -53,6 +57,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password(password) + .userStatus(UserStatus.ACTIVE) .create(); } @@ -64,6 +69,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTOR) .password("mentor123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -75,6 +81,31 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.ADMIN) .password("admin123") + .userStatus(UserStatus.ACTIVE) + .create(); + } + + public SiteUser 신고된_사용자(String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("reported@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("reported123") + .userStatus(UserStatus.REPORTED) + .create(); + } + + public SiteUser 차단된_사용자(String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("banned@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("banned123") + .userStatus(UserStatus.BANNED) .create(); } } diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java index 901de4d6a..e4497f24c 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java @@ -4,6 +4,7 @@ import com.example.solidconnection.siteuser.domain.ExchangeStatus; import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -22,6 +23,7 @@ public class SiteUserFixtureBuilder { private String profileImageUrl; private Role role; private String password; + private UserStatus userStatus; public SiteUserFixtureBuilder siteUser() { return new SiteUserFixtureBuilder(siteUserRepository, passwordEncoder); @@ -57,6 +59,11 @@ public SiteUserFixtureBuilder password(String password) { return this; } + public SiteUserFixtureBuilder userStatus(UserStatus userStatus) { + this.userStatus = userStatus; + return this; + } + public SiteUser create() { SiteUser siteUser = new SiteUser( email, @@ -65,7 +72,8 @@ public SiteUser create() { ExchangeStatus.CONSIDERING, role, authType, - passwordEncoder.encode(password) + passwordEncoder.encode(password), + userStatus != null ? userStatus : UserStatus.ACTIVE ); return siteUserRepository.save(siteUser); } diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java new file mode 100644 index 000000000..b73e4f055 --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserBanDuration; + +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UserBanFixture { + + private final UserBanFixtureBuilder userBanFixtureBuilder; + + private static final long DEFAULT_ADMIN_ID = 1L; + + public UserBan 만료된_차단(long bannedUserId) { + return userBanFixtureBuilder.userBan() + .bannedUserId(bannedUserId) + .bannedBy(DEFAULT_ADMIN_ID) + .duration(UserBanDuration.ONE_DAY) + .expiredAt(ZonedDateTime.now().minusDays(1)) + .create(); + } + + public UserBan 수동_차단_해제(long bannedUserId, long adminId) { + UserBan userBan = userBanFixtureBuilder.userBan() + .bannedUserId(bannedUserId) + .bannedBy(adminId) + .duration(UserBanDuration.SEVEN_DAYS) + .expiredAt(ZonedDateTime.now().plusDays(7)) + .create(); + userBan.manuallyUnban(adminId); + return userBan; + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java new file mode 100644 index 000000000..6ad095979 --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java @@ -0,0 +1,49 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserBanDuration; +import com.example.solidconnection.siteuser.repository.UserBanRepository; +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UserBanFixtureBuilder { + + private final UserBanRepository userBanRepository; + + private Long bannedUserId; + private Long bannedBy; + private UserBanDuration duration; + private ZonedDateTime expiredAt; + + public UserBanFixtureBuilder userBan() { + return new UserBanFixtureBuilder(userBanRepository); + } + + public UserBanFixtureBuilder bannedUserId(Long bannedUserId) { + this.bannedUserId = bannedUserId; + return this; + } + + public UserBanFixtureBuilder bannedBy(Long bannedBy) { + this.bannedBy = bannedBy; + return this; + } + + public UserBanFixtureBuilder duration(UserBanDuration duration) { + this.duration = duration; + return this; + } + + public UserBanFixtureBuilder expiredAt(ZonedDateTime expiredAt) { + this.expiredAt = expiredAt; + return this; + } + + public UserBan create() { + UserBan userBan = new UserBan(bannedUserId, bannedBy, duration, expiredAt); + return userBanRepository.save(userBan); + } +} From f102528b2516c615d72bfeb140ca8d4df099aab4 Mon Sep 17 00:00:00 2001 From: hyungjun <115551339+sukangpunch@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:01:12 +0900 Subject: [PATCH 29/31] =?UTF-8?q?feat:=20API=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85,=20=EC=BF=BC=EB=A6=AC=20=EB=B3=84=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=A0=84=EC=86=A1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#602)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: HTTP 요청/응답 로깅 필터 구현 - traceId 기반 요청 추적 - 요청/응답 로깅 - CustomExceptionHandler와 중복 로깅 방지 - Actuator 엔드포인트 로깅 제외 * feat: ExceptionHandler에 중복 로깅 방지 플래그 및 userId 로깅 추가 * feat: API 수행시간 로깅 인터셉터 추가 * feat: ApiPerf 인터셉터, Logging 필터 빈 등록 * refactor: logback 설정 변경 - info, warn, error, api_perf 로 로그 파일 분리해서 관리 * feat: 쿼리 별 수행시간 메트릭 모니터링 추가 * feat: 데이터소스 프록시 의존성 및 config 파일 추가 * feat: 데이터 소스 프록시가 metric을 찍을 수 있도록 listener 클래스 추가 * feat: 요청 시 method, uri 정보를 listener에서 활용하기 위해 RequestContext 및 관련 interceptor 추가 * refactor: 비효율적인 Time 빌더 생성 개선 - Time.builder 를 사용하면 매번 빌더를 생성하여 비효율적인 문제를 meterRegistry.timer 방식으로 해결 * feat: 로깅을 위해 HttpServeletRequest 속성에 userId 추가 * refactor: logback 설정 중 local은 console만 찍도록 수정 * refactor: FILE_PATTERN -> LOG_PATTERN 으로 수정 * test: TokenAuthenticationFilter에서 request에 userId 설정 검증 추가 - principal 조회 예외를 막기 위해 siteUserDetailsService given 추가 * refacotr: 코드 래빗 리뷰사항 반영 * test: 중복되는 테스트 제거 * refactor: 사용하지 않는 필드 제거 * refactor: 리뷰 내용 반영 * refactor: ApiPerformanceInterceptor에서 uri 정규화 관련 코드 제거 * refactor: ApiPerformanceInterceptor에서 if-return 문을 if-else 문으로 수정 * refactor: 추가한 interceptor 의 설정에 actuator 경로 무시하도록 셋팅 * refactor: 중복되는 의존성 제거 * refactor: 로깅 시 민감한 쿼리 파라미터 마스킹 - EXCLUDE_QUERIES 에 해당하는 쿼리 파라미터 KEY 값의 VALUE 를 masking 값으로 치환 * refactor: 예외 처리 후에도 Response 로그 찍도록 수정 * refactor: CustomExceptionHandler 원상복구 - Response 로그를 통해 user를 추적할 수 있으므로 로그에 userId 를 추가하지 않습니다 * refactor: 리뷰 사항 반영 * refactor: RequestContext 빌더 제거 * refactor: RequestContextInterceptor import 수정 * refactor: logback yml 파일에서 timestamp 서버 시간과 동일한 규격으로 수정 * refactor: ApiPerformanceInterceptor 에서 동일 내용 로그 중복으로 찍는 문제 수정 * fix: decode를 두 번 하는 문제 수정 * test: 로깅 관련 filter, interceptor 테스트 추가 * refactor: 코드래빗 리뷰사항 반영 * test: contains 로 비교하던 검증 로직을 isEqualTo 로 수정 * test: preHandle 테스트 에서 result 값을 항상 검증 * refactor: 단위테스트에 TestContainer 어노테이션 제거 * fix: conflict 해결 --- build.gradle | 3 + .../datasource/DataSourceProxyConfig.java | 29 ++ .../common/config/web/WebMvcConfig.java | 27 +- .../common/filter/HttpLoggingFilter.java | 156 ++++++++++ .../ApiPerformanceInterceptor.java | 67 ++++ .../common/interceptor/RequestContext.java | 14 + .../interceptor/RequestContextHolder.java | 18 ++ .../RequestContextInterceptor.java | 36 +++ .../common/listener/QueryMetricsListener.java | 53 ++++ .../filter/TokenAuthenticationFilter.java | 8 + src/main/resources/logback-spring.xml | 106 +++++-- .../common/filter/HttpLoggingFilterTest.java | 241 +++++++++++++++ .../ApiPerformanceInterceptorTest.java | 199 ++++++++++++ .../RequestContextInterceptorTest.java | 112 +++++++ .../listener/QueryMetricsListenerTest.java | 289 ++++++++++++++++++ .../filter/TokenAuthenticationFilterTest.java | 17 +- 16 files changed, 1351 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java create mode 100644 src/main/java/com/example/solidconnection/common/filter/HttpLoggingFilter.java create mode 100644 src/main/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptor.java create mode 100644 src/main/java/com/example/solidconnection/common/interceptor/RequestContext.java create mode 100644 src/main/java/com/example/solidconnection/common/interceptor/RequestContextHolder.java create mode 100644 src/main/java/com/example/solidconnection/common/interceptor/RequestContextInterceptor.java create mode 100644 src/main/java/com/example/solidconnection/common/listener/QueryMetricsListener.java create mode 100644 src/test/java/com/example/solidconnection/common/filter/HttpLoggingFilterTest.java create mode 100644 src/test/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptorTest.java create mode 100644 src/test/java/com/example/solidconnection/common/interceptor/RequestContextInterceptorTest.java create mode 100644 src/test/java/com/example/solidconnection/common/listener/QueryMetricsListenerTest.java diff --git a/build.gradle b/build.gradle index 91cc2e77d..deefc611b 100644 --- a/build.gradle +++ b/build.gradle @@ -68,6 +68,9 @@ dependencies { implementation 'org.hibernate.validator:hibernate-validator' implementation 'com.amazonaws:aws-java-sdk-s3:1.12.782' implementation 'org.springframework.boot:spring-boot-starter-websocket' + + // Database Proxy + implementation 'net.ttddyy.observation:datasource-micrometer:1.2.0' } tasks.named('test', Test) { diff --git a/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java b/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java new file mode 100644 index 000000000..b7bf0b008 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.common.config.datasource; + +import com.example.solidconnection.common.listener.QueryMetricsListener; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@RequiredArgsConstructor +@Configuration +public class DataSourceProxyConfig { + + private final QueryMetricsListener queryMetricsListener; + + @Bean + @Primary + public DataSource proxyDataSource(DataSourceProperties props) { + DataSource dataSource = props.initializeDataSourceBuilder().build(); + + return ProxyDataSourceBuilder + .create(dataSource) + .listener(queryMetricsListener) + .name("main") + .build(); + } +} diff --git a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java index 47d70689d..1d99274db 100644 --- a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java @@ -1,11 +1,17 @@ package com.example.solidconnection.common.config.web; import com.example.solidconnection.common.interceptor.BannedUserInterceptor; +import com.example.solidconnection.common.filter.HttpLoggingFilter; +import com.example.solidconnection.common.interceptor.ApiPerformanceInterceptor; +import com.example.solidconnection.common.interceptor.RequestContextInterceptor; import com.example.solidconnection.common.resolver.AuthorizedUserResolver; import com.example.solidconnection.common.resolver.CustomPageableHandlerMethodArgumentResolver; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -17,6 +23,9 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthorizedUserResolver authorizedUserResolver; private final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver; private final BannedUserInterceptor bannedUserInterceptor; + private final HttpLoggingFilter httpLoggingFilter; + private final ApiPerformanceInterceptor apiPerformanceInterceptor; + private final RequestContextInterceptor requestContextInterceptor; @Override public void addArgumentResolvers(List resolvers) { @@ -27,8 +36,24 @@ public void addArgumentResolvers(List resolvers) } @Override - public void addInterceptors(InterceptorRegistry registry) { + public void addInterceptors(InterceptorRegistry registry){ + registry.addInterceptor(apiPerformanceInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/actuator/**"); + + registry.addInterceptor(requestContextInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/actuator/**"); + registry.addInterceptor(bannedUserInterceptor) .addPathPatterns("/posts/**", "/comments/**", "/chats/**", "/boards/**"); } + + @Bean + public FilterRegistrationBean customHttpLoggingFilter() { + FilterRegistrationBean filterBean = new FilterRegistrationBean<>(); + filterBean.setFilter(httpLoggingFilter); + filterBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return filterBean; + } } diff --git a/src/main/java/com/example/solidconnection/common/filter/HttpLoggingFilter.java b/src/main/java/com/example/solidconnection/common/filter/HttpLoggingFilter.java new file mode 100644 index 000000000..74f2dfa6c --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/filter/HttpLoggingFilter.java @@ -0,0 +1,156 @@ +package com.example.solidconnection.common.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +@Component +public class HttpLoggingFilter extends OncePerRequestFilter { + + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); + private static final List EXCLUDE_PATTERNS = List.of("/actuator/**"); + private static final List EXCLUDE_QUERIES = List.of("token"); + private static final String MASK_VALUE = "****"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + // 1) traceId 부여 + String traceId = generateTraceId(); + MDC.put("traceId", traceId); + + boolean excluded = isExcluded(request); + + // 2) 로깅 제외 대상이면 그냥 통과 (traceId는 유지: 추후 하위 레이어 로그에도 붙음) + if (excluded) { + try { + filterChain.doFilter(request, response); + } finally { + MDC.clear(); + } + return; + } + + printRequestUri(request); + + try { + filterChain.doFilter(request, response); + printResponse(request, response); + } finally { + MDC.clear(); + } + } + + private boolean isExcluded(HttpServletRequest req) { + String path = req.getRequestURI(); + for (String p : EXCLUDE_PATTERNS) { + if (PATH_MATCHER.match(p, path)) { + return true; + } + } + return false; + } + + private String generateTraceId() { + return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + private void printRequestUri(HttpServletRequest request) { + String methodType = request.getMethod(); + String uri = buildDecodedRequestUri(request); + log.info("[REQUEST] {} {}", methodType, uri); + } + + private void printResponse( + HttpServletRequest request, + HttpServletResponse response + ) { + Long userId = (Long) request.getAttribute("userId"); + String uri = buildDecodedRequestUri(request); + HttpStatus status = HttpStatus.valueOf(response.getStatus()); + + log.info("[RESPONSE] {} userId = {}, ({})", uri, userId, status); + } + + private String buildDecodedRequestUri(HttpServletRequest request) { + String path = request.getRequestURI(); + String query = request.getQueryString(); + + if(query == null || query.isBlank()){ + return path; + } + + String decodedQuery = decodeQuery(query); + String maskedQuery = maskSensitiveParams(decodedQuery); + + return path + "?" + maskedQuery; + } + + private String decodeQuery(String rawQuery) { + if(rawQuery == null || rawQuery.isBlank()){ + return rawQuery; + } + + try { + return URLDecoder.decode(rawQuery, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + log.warn("Query 디코딩 실패 parameter: {}, msg: {}", rawQuery, e.getMessage()); + return rawQuery; + } + } + + private String maskSensitiveParams(String decodedQuery) { + String[] params = decodedQuery.split("&"); + StringBuilder maskedQuery = new StringBuilder(); + + for(int i = 0; i < params.length; i++){ + String param = params[i]; + + if(!param.contains("=")){ + maskedQuery.append(param); + }else{ + int equalIndex = param.indexOf("="); + String key = param.substring(0, equalIndex); + + if(isSensitiveParam(key)){ + maskedQuery.append(key).append("=").append(MASK_VALUE); + }else{ + maskedQuery.append(param); + } + } + + if(i < params.length - 1){ + maskedQuery.append("&"); + } + } + + return maskedQuery.toString(); + } + + private boolean isSensitiveParam(String paramKey) { + for (String sensitiveParam : EXCLUDE_QUERIES){ + if(sensitiveParam.equalsIgnoreCase(paramKey)){ + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptor.java b/src/main/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptor.java new file mode 100644 index 000000000..50a95f937 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptor.java @@ -0,0 +1,67 @@ +package com.example.solidconnection.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ApiPerformanceInterceptor implements HandlerInterceptor { + private static final String START_TIME_ATTRIBUTE = "startTime"; + private static final String REQUEST_URI_ATTRIBUTE = "requestUri"; + private static final int RESPONSE_TIME_THRESHOLD = 3_000; + private static final Logger API_PERF = LoggerFactory.getLogger("API_PERF"); + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler + ) throws Exception { + + long startTime = System.currentTimeMillis(); + + request.setAttribute(START_TIME_ATTRIBUTE, startTime); + request.setAttribute(REQUEST_URI_ATTRIBUTE, request.getRequestURI()); + + return true; + } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex + ) throws Exception { + Long startTime = (Long) request.getAttribute(START_TIME_ATTRIBUTE); + if(startTime == null) { + return; + } + + long responseTime = System.currentTimeMillis() - startTime; + + String uri = request.getRequestURI(); + String method = request.getMethod(); + int status = response.getStatus(); + + if (responseTime > RESPONSE_TIME_THRESHOLD) { + API_PERF.warn( + "type=API_Performance method_type={} uri={} response_time={} status={}", + method, uri, responseTime, status + ); + } + else { + API_PERF.info( + "type=API_Performance method_type={} uri={} response_time={} status={}", + method, uri, responseTime, status + ); + } + } +} diff --git a/src/main/java/com/example/solidconnection/common/interceptor/RequestContext.java b/src/main/java/com/example/solidconnection/common/interceptor/RequestContext.java new file mode 100644 index 000000000..1f4d2790c --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/interceptor/RequestContext.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.common.interceptor; + +import lombok.Getter; + +@Getter +public class RequestContext { + private final String httpMethod; + private final String bestMatchPath; + + public RequestContext(String httpMethod, String bestMatchPath) { + this.httpMethod = httpMethod; + this.bestMatchPath = bestMatchPath; + } +} diff --git a/src/main/java/com/example/solidconnection/common/interceptor/RequestContextHolder.java b/src/main/java/com/example/solidconnection/common/interceptor/RequestContextHolder.java new file mode 100644 index 000000000..0c786bf10 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/interceptor/RequestContextHolder.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.common.interceptor; + +public class RequestContextHolder { + private static final ThreadLocal CONTEXT = new ThreadLocal<>(); + + public static void initContext(RequestContext requestContext) { + CONTEXT.remove(); + CONTEXT.set(requestContext); + } + + public static RequestContext getContext() { + return CONTEXT.get(); + } + + public static void clear(){ + CONTEXT.remove(); + } +} diff --git a/src/main/java/com/example/solidconnection/common/interceptor/RequestContextInterceptor.java b/src/main/java/com/example/solidconnection/common/interceptor/RequestContextInterceptor.java new file mode 100644 index 000000000..e42b14e11 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/interceptor/RequestContextInterceptor.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.common.interceptor; + +import static org.springframework.web.servlet.HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class RequestContextInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler + ) { + String httpMethod = request.getMethod(); + String bestMatchPath = (String) request.getAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE); + + RequestContext context = new RequestContext(httpMethod, bestMatchPath); + RequestContextHolder.initContext(context); + + return true; + } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, Exception ex + ) { + RequestContextHolder.clear(); + } +} diff --git a/src/main/java/com/example/solidconnection/common/listener/QueryMetricsListener.java b/src/main/java/com/example/solidconnection/common/listener/QueryMetricsListener.java new file mode 100644 index 000000000..8f3258b6b --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/listener/QueryMetricsListener.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.common.listener; + +import com.example.solidconnection.common.interceptor.RequestContext; +import com.example.solidconnection.common.interceptor.RequestContextHolder; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.List; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import net.ttddyy.dsproxy.ExecutionInfo; +import net.ttddyy.dsproxy.QueryInfo; +import net.ttddyy.dsproxy.listener.QueryExecutionListener; +import org.springframework.stereotype.Component; + + +@RequiredArgsConstructor +@Component +public class QueryMetricsListener implements QueryExecutionListener { + + private final MeterRegistry meterRegistry; + + @Override + public void beforeQuery(ExecutionInfo executionInfo, List list) { + + } + + @Override + public void afterQuery(ExecutionInfo exec, List queries) { + long elapsedMs = exec.getElapsedTime(); + String sql = queries.isEmpty() ? "" : queries.get(0).getQuery(); + String type = guessType(sql); + + RequestContext rc = RequestContextHolder.getContext(); + String httpMethod = (rc != null && rc.getHttpMethod() != null) ? rc.getHttpMethod() : "-"; + String httpPath = (rc != null && rc.getBestMatchPath() != null) ? rc.getBestMatchPath() : "-"; + + meterRegistry.timer( + "db.query", + "sql_type", type, + "http_method", httpMethod, + "http_path", httpPath + ).record(elapsedMs, TimeUnit.MILLISECONDS); + } + + private String guessType(String sql) { + if (sql == null) return "OTHER"; + String s = sql.trim().toUpperCase(); + if (s.startsWith("SELECT")) return "SELECT"; + if (s.startsWith("INSERT")) return "INSERT"; + if (s.startsWith("UPDATE")) return "UPDATE"; + if (s.startsWith("DELETE")) return "DELETE"; + return "UNKNOWN"; + } +} diff --git a/src/main/java/com/example/solidconnection/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/example/solidconnection/security/filter/TokenAuthenticationFilter.java index 8c8dc8f30..6e1899dd3 100644 --- a/src/main/java/com/example/solidconnection/security/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/example/solidconnection/security/filter/TokenAuthenticationFilter.java @@ -2,6 +2,7 @@ import com.example.solidconnection.security.authentication.TokenAuthentication; import com.example.solidconnection.security.infrastructure.AuthorizationHeaderParser; +import com.example.solidconnection.security.userdetails.SiteUserDetails; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -34,6 +35,7 @@ public void doFilterInternal(@NonNull HttpServletRequest request, TokenAuthentication authToken = new TokenAuthentication(token); Authentication auth = authenticationManager.authenticate(authToken); SecurityContextHolder.getContext().setAuthentication(auth); + extractIdFromAuthentication(request, auth); }); filterChain.doFilter(request, response); @@ -45,4 +47,10 @@ private Optional resolveToken(HttpServletRequest request) { } return authorizationHeaderParser.parseToken(request); } + + private void extractIdFromAuthentication(HttpServletRequest request, Authentication auth) { + SiteUserDetails principal = (SiteUserDetails) auth.getPrincipal(); + Long id = principal.getSiteUser().getId(); + request.setAttribute("userId", id); + } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index e179be0fb..52d0bb4e8 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -2,34 +2,96 @@ - - + - - /var/log/spring/solid-connection-server.log + + + - - - /var/log/spring/solid-connection-server.%d{yyyy-MM-dd}.log - 30 - + + + ${LOG_PATH}/info/info.log + + ${LOG_PATH}/info/info.%d{yyyy-MM-dd}.log + 7 + + + ${LOG_PATTERN} + + + INFO + ACCEPT + DENY + + - - - timestamp=%d{yyyy-MM-dd'T'HH:mm:ss.SSS} level=%-5level thread=%thread logger=%logger{36} - message=%msg%n - - - + + + ${LOG_PATH}/warn/warn.log + + ${LOG_PATH}/warn/warn.%d{yyyy-MM-dd}.log + 7 + + + ${LOG_PATTERN} + + + WARN + ACCEPT + DENY + + - - - + + + ${LOG_PATH}/error/error.log + + ${LOG_PATH}/error/error.%d{yyyy-MM-dd}.log + 7 + + + ${LOG_PATTERN} + + + ERROR + ACCEPT + DENY + + - + + + ${LOG_PATH}/api-perf/api-perf.log + + ${LOG_PATH}/api-perf/api-perf.%d{yyyy-MM-dd}.log + 7 + + + ${LOG_PATTERN} + + + + + + + + + + + + + + + + + + + + - + - + \ No newline at end of file diff --git a/src/test/java/com/example/solidconnection/common/filter/HttpLoggingFilterTest.java b/src/test/java/com/example/solidconnection/common/filter/HttpLoggingFilterTest.java new file mode 100644 index 000000000..815370bfb --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/filter/HttpLoggingFilterTest.java @@ -0,0 +1,241 @@ +package com.example.solidconnection.common.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +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.slf4j.LoggerFactory; +import org.slf4j.MDC; + +@DisplayName("HttpLoggingFilter 테스트") +class HttpLoggingFilterTest { + + private HttpLoggingFilter filter; + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + private ListAppender listAppender; + private Logger logger; + + @BeforeEach + void setUp() { + filter = new HttpLoggingFilter(); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + filterChain = mock(FilterChain.class); + + logger = (Logger) LoggerFactory.getLogger(HttpLoggingFilter.class); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + } + + @AfterEach + void tearDown() { + MDC.clear(); + logger.detachAppender(listAppender); + listAppender.stop(); + } + + @Nested + class TraceId_생성 { + + @Test + void 요청마다_traceId를_생성한다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/api/test"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + + AtomicReference capturedTraceId = new AtomicReference<>(); + + doAnswer(invocation ->{ + capturedTraceId.set(MDC.get("traceId")); + return null; + }).when(filterChain).doFilter(request, response); + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + String traceId = capturedTraceId.get(); + assertAll( + () -> assertThat(traceId).isNotNull(), + () -> assertThat(traceId).hasSize(16), + () -> assertThat(traceId).matches("[a-f0-9]{16}") + ); + verify(filterChain).doFilter(request, response); + } + } + + @Nested + class 로깅_제외_패턴 { + + @Test + void actuator_경로는_로깅에서_제외된다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/actuator/health"); + when(request.getMethod()).thenReturn("GET"); + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertAll( + () -> assertThat(listAppender.list).noneMatch(event -> event.getFormattedMessage().contains("[REQUEST]")), + () -> assertThat(listAppender.list).noneMatch(event -> event.getFormattedMessage().contains("[RESPONSE]")) + ); + verify(filterChain).doFilter(request, response); + } + + @Test + void 일반_경로는_로깅된다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + String expectedRequestLog = "[REQUEST] GET /api/users"; + String expectedResponseLog = "[RESPONSE] /api/users userId = null, (200 OK)"; + + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertAll( + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedRequestLog)), + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedResponseLog)) + ); + verify(filterChain).doFilter(request, response); + } + } + + @Nested + class 민감한_쿼리_파라미터_마스킹 { + + @Test + void token_파라미터는_마스킹된다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/api/auth"); + when(request.getQueryString()).thenReturn("token=secret123&userId=1"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + String expectedRequestLog = "[REQUEST] GET /api/auth?token=****&userId=1"; + String expectedResponseLog = "[RESPONSE] /api/auth?token=****&userId=1 userId = null, (200 OK)"; + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertAll( + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedRequestLog)), + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedResponseLog)) + ); + verify(filterChain).doFilter(request, response); + } + + @Test + void 일반_파라미터는_마스킹되지_않는다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/api/users"); + when(request.getQueryString()).thenReturn("name=홍길동&age=20"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + String expectedRequestLog = "[REQUEST] GET /api/users?name=홍길동&age=20"; + String expectedResponseLog = "[RESPONSE] /api/users?name=홍길동&age=20 userId = null, (200 OK)"; + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertAll( + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedRequestLog)), + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedResponseLog)) + ); + verify(filterChain).doFilter(request, response); + } + } + + @Nested + class 쿼리_파라미터_디코딩 { + + @Test + void URL_인코딩된_파라미터를_디코딩한다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/api/search"); + when(request.getQueryString()).thenReturn("keyword=%ED%99%8D%EA%B8%B8%EB%8F%99"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + String expectedParameter = "홍길동"; + String expectedRequestLog = "[REQUEST] GET /api/search?keyword=" + expectedParameter; + String expectedResponseLog = "[RESPONSE] /api/search?keyword=" + expectedParameter + " userId = null, (200 OK)"; + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertAll( + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedRequestLog)), + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedResponseLog)) + ); + verify(filterChain).doFilter(request, response); + } + + @Test + void 디코딩_실패_시_원본_쿼리를_사용한다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/api/search"); + when(request.getQueryString()).thenReturn("invalid=%"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + String expectedRequestLog = "[REQUEST] GET /api/search?invalid=%"; + String expectedResponseLog = "[RESPONSE] /api/search?invalid=% userId = null, (200 OK)"; + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertAll( + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedRequestLog)), + () -> assertThat(listAppender.list).anyMatch(event -> event.getFormattedMessage().contains(expectedResponseLog)) + ); + verify(filterChain).doFilter(request, response); + } + } + + @Nested + class MDC_정리 { + + @Test + void 요청_완료_후_MDC가_정리된다() throws ServletException, IOException { + // given + when(request.getRequestURI()).thenReturn("/api/test"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertThat(MDC.get("traceId")).isNull(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptorTest.java b/src/test/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptorTest.java new file mode 100644 index 000000000..b43d854ed --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/interceptor/ApiPerformanceInterceptorTest.java @@ -0,0 +1,199 @@ +package com.example.solidconnection.common.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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.slf4j.LoggerFactory; + +@DisplayName("ApiPerformanceInterceptor 테스트") +class ApiPerformanceInterceptorTest { + + private ApiPerformanceInterceptor interceptor; + private HttpServletRequest request; + private HttpServletResponse response; + private Object handler; + + private ListAppender listAppender; + private Logger logger; + + @BeforeEach + void setUp() { + interceptor = new ApiPerformanceInterceptor(); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + handler = new Object(); + + logger = (Logger) LoggerFactory.getLogger("API_PERF"); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(listAppender); + listAppender.stop(); + } + + @Nested + class PreHandle_메서드 { + + @Test + void 시작_시간을_request에_저장한다() throws Exception { + // given + when(request.getRequestURI()).thenReturn("/api/test"); + long beforeTime = System.currentTimeMillis(); + + // when + interceptor.preHandle(request, response, handler); + + // then + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(Object.class); + + verify(request, times(2)).setAttribute(keyCaptor.capture(), valueCaptor.capture()); + + List capturedKeys = keyCaptor.getAllValues(); + List capturedValues = valueCaptor.getAllValues(); + + assertThat(capturedKeys).contains("startTime"); + Long startTime = (Long) capturedValues.get(capturedKeys.indexOf("startTime")); + assertThat(startTime) + .isGreaterThanOrEqualTo(beforeTime); + + assertThat(capturedKeys).contains("requestUri"); + String uri = (String) capturedValues.get(capturedKeys.indexOf("requestUri")); + assertThat(uri).isEqualTo("/api/test"); + } + + @Test + void preHandle_항상_true를_반환한다() throws Exception { + // given + when(request.getRequestURI()).thenReturn("/api/test"); + + // when + boolean result = interceptor.preHandle(request, response, handler); + + // then + assertThat(result).isTrue(); + } + } + + @Nested + class AfterCompletion_메서드 { + + @Test + void 응답_시간을_계산하고_로그를_남긴다() throws Exception { + // given + long startTime = System.currentTimeMillis(); + when(request.getAttribute("startTime")).thenReturn(startTime); + when(request.getRequestURI()).thenReturn("/api/test"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + String expectedApiPerfLog = "type=API_Performance"; + + // when + interceptor.afterCompletion(request, response, handler, null); + + // then + ILoggingEvent logEvent = listAppender.list.stream() + .filter(event -> event.getFormattedMessage().contains(expectedApiPerfLog)) + .findFirst() + .orElseThrow(); + assertAll( + () -> assertThat(logEvent.getLevel().toString()).isEqualTo("INFO"), + () -> assertThat(logEvent.getFormattedMessage()).contains("uri=/api/test"), + () -> assertThat(logEvent.getFormattedMessage()).contains("method_type=GET"), + () -> assertThat(logEvent.getFormattedMessage()).contains("status=200") + ); + } + + @Test + void 응답_시간이_3초를_초과하면_WARN_로그를_남긴다() throws Exception { + // given + long startTime = System.currentTimeMillis() - 4000; // 4초 전 + when(request.getAttribute("startTime")).thenReturn(startTime); + when(request.getRequestURI()).thenReturn("/api/slow"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + String expectedApiPerfLog = "type=API_Performance"; + + // when + interceptor.afterCompletion(request, response, handler, null); + + // then + ILoggingEvent logEvent = listAppender.list.stream() + .filter(event -> event.getFormattedMessage().contains(expectedApiPerfLog)) + .findFirst() + .orElseThrow(); + assertAll( + () -> assertThat(logEvent.getLevel().toString()).isEqualTo("WARN"), + () -> assertThat(logEvent.getFormattedMessage()).contains("uri=/api/slow"), + () -> assertThat(logEvent.getFormattedMessage()).contains("method_type=GET"), + () -> assertThat(logEvent.getFormattedMessage()).contains("status=200") + ); + } + + @Test + void startTime이_없으면_로그를_남기지_않는다() throws Exception { + // given + when(request.getAttribute("startTime")).thenReturn(null); + String noExpectedApiPerfLog = "type=API_Performance"; + + // when + interceptor.afterCompletion(request, response, handler, null); + + // then + assertThat(listAppender.list).noneMatch(event -> event.getFormattedMessage().contains(noExpectedApiPerfLog)); + } + } + + @Nested + class 예외_발생_시 { + + @Test + void 예외가_발생해도_로그를_정상_기록한다() throws Exception { + // given + long startTime = System.currentTimeMillis(); + when(request.getAttribute("startTime")).thenReturn(startTime); + when(request.getRequestURI()).thenReturn("/api/error"); + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(500); + + Exception ex = new RuntimeException("Test exception"); + + String expectedApiPerfLog = "type=API_Performance"; + + // when + interceptor.afterCompletion(request, response, handler, ex); + + // then + ILoggingEvent logEvent = listAppender.list.stream() + .filter(event -> event.getFormattedMessage().contains(expectedApiPerfLog)) + .findFirst() + .orElseThrow(); + assertAll( + () -> assertThat(logEvent.getLevel().toString()).isEqualTo("INFO"), + () -> assertThat(logEvent.getFormattedMessage()).contains("uri=/api/error"), + () -> assertThat(logEvent.getFormattedMessage()).contains("method_type=GET"), + () -> assertThat(logEvent.getFormattedMessage()).contains("status=500") + ); + } + } +} diff --git a/src/test/java/com/example/solidconnection/common/interceptor/RequestContextInterceptorTest.java b/src/test/java/com/example/solidconnection/common/interceptor/RequestContextInterceptorTest.java new file mode 100644 index 000000000..6d463e958 --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/interceptor/RequestContextInterceptorTest.java @@ -0,0 +1,112 @@ +package com.example.solidconnection.common.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.web.servlet.HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("RequestContextInterceptor 테스트") +class RequestContextInterceptorTest { + + private RequestContextInterceptor interceptor; + private HttpServletRequest request; + private HttpServletResponse response; + private Object handler; + + @BeforeEach + void setUp() { + interceptor = new RequestContextInterceptor(); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + handler = new Object(); + } + + @AfterEach + void tearDown() { + RequestContextHolder.clear(); + } + + @Nested + class PreHandle_메서드 { + + @Test + void RequestContext를_초기화_한_후_true를_리턴한다() { + // given + when(request.getMethod()).thenReturn("GET"); + when(request.getAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE)).thenReturn("/api/users/{id}"); + + // when + boolean result = interceptor.preHandle(request, response, handler); + + // then + assertThat(result).isTrue(); + + RequestContext context = RequestContextHolder.getContext(); + assertThat(context).isNotNull(); + assertThat(context.getHttpMethod()).isEqualTo("GET"); + assertThat(context.getBestMatchPath()).isEqualTo("/api/users/{id}"); + } + + @Test + void best_matching_pattern이_null이면_null을_저장한다() { + // given + when(request.getMethod()).thenReturn("GET"); + when(request.getAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE)).thenReturn(null); + + // when + boolean result = interceptor.preHandle(request, response, handler); + + // then + assertThat(result).isTrue(); + + RequestContext context = RequestContextHolder.getContext(); + assertThat(context.getBestMatchPath()).isNull(); + } + } + + @Nested + class AfterCompletion_메서드 { + + @Test + void RequestContext를_정리한다() { + // given + when(request.getMethod()).thenReturn("GET"); + when(request.getAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE)).thenReturn("/api/users"); + + interceptor.preHandle(request, response, handler); + assertThat(RequestContextHolder.getContext()).isNotNull(); + + // when + interceptor.afterCompletion(request, response, handler, null); + + // then + assertThat(RequestContextHolder.getContext()).isNull(); + } + + @Test + void 예외가_발생해도_RequestContext를_정리한다() { + // given + when(request.getMethod()).thenReturn("POST"); + when(request.getAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE)).thenReturn("/api/users"); + + interceptor.preHandle(request, response, handler); + assertThat(RequestContextHolder.getContext()).isNotNull(); + + Exception ex = new RuntimeException("Test exception"); + + // when + interceptor.afterCompletion(request, response, handler, ex); + + // then + assertThat(RequestContextHolder.getContext()).isNull(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/common/listener/QueryMetricsListenerTest.java b/src/test/java/com/example/solidconnection/common/listener/QueryMetricsListenerTest.java new file mode 100644 index 000000000..e0ca19a4c --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/listener/QueryMetricsListenerTest.java @@ -0,0 +1,289 @@ +package com.example.solidconnection.common.listener; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.solidconnection.common.interceptor.RequestContext; +import com.example.solidconnection.common.interceptor.RequestContextHolder; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.List; +import java.util.concurrent.TimeUnit; +import net.ttddyy.dsproxy.ExecutionInfo; +import net.ttddyy.dsproxy.QueryInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("QueryMetricsListener 테스트") +class QueryMetricsListenerTest { + + private QueryMetricsListener listener; + private MeterRegistry meterRegistry; + private ExecutionInfo executionInfo; + + @BeforeEach + void setUp() { + meterRegistry = mock(MeterRegistry.class); + listener = new QueryMetricsListener(meterRegistry); + executionInfo = mock(ExecutionInfo.class); + } + + @AfterEach + void tearDown() { + RequestContextHolder.clear(); + } + + @Nested + class 쿼리_메트릭_수집 { + + @Test + void SELECT_쿼리의_실행_시간을_기록한다() { + // given + String sql = "SELECT * FROM users WHERE id = ?"; + QueryInfo queryInfo = new QueryInfo(); + queryInfo.setQuery(sql); + + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), any(String.class), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("SELECT"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + + @Test + void INSERT_쿼리의_실행_시간을_기록한다() { + // given + String sql = "INSERT INTO users (name) VALUES (?)"; + QueryInfo queryInfo = new QueryInfo(); + queryInfo.setQuery(sql); + + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), any(String.class), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("INSERT"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + + @Test + void UPDATE_쿼리의_실행_시간을_기록한다() { + // given + String sql = "UPDATE users SET name = ? WHERE id = ?"; + QueryInfo queryInfo = new QueryInfo(); + queryInfo.setQuery(sql); + + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), eq("UPDATE"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("UPDATE"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + + @Test + void DELETE_쿼리의_실행_시간을_기록한다() { + // given + String sql = "DELETE FROM users WHERE id = ?"; + QueryInfo queryInfo = new QueryInfo(); + queryInfo.setQuery(sql); + + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), eq("DELETE"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("DELETE"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + + @Test + void 알수없는_쿼리는_UNKNOWN으로_기록한다() { + // given + String sql = "SHOW TABLES"; + QueryInfo queryInfo = new QueryInfo(); + queryInfo.setQuery(sql); + + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), any(String.class), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("UNKNOWN"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + + @Test + void null_쿼리는_OTHER로_기록한다() { + // given + QueryInfo queryInfo = new QueryInfo(); + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), any(String.class), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("OTHER"), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + } + + @Nested + class RequestContext_연동 { + + @Test + void RequestContext가_있으면_HTTP_정보를_포함한다() { + // given + RequestContext context = new RequestContext("GET", "/api/users"); + RequestContextHolder.initContext(context); + + String sql = "SELECT * FROM users"; + QueryInfo queryInfo = new QueryInfo(); + queryInfo.setQuery(sql); + + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), any(String.class), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("SELECT"), + eq("http_method"), eq("GET"), + eq("http_path"), eq("/api/users") + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + + @Test + void RequestContext가_없으면_기본값을_사용한다() { + // given + String sql = "SELECT * FROM users"; + QueryInfo queryInfo = new QueryInfo(); + queryInfo.setQuery(sql); + + when(executionInfo.getElapsedTime()).thenReturn(100L); + + Timer timer = mock(Timer.class); + when(meterRegistry.timer( + eq("db.query"), + eq("sql_type"), any(String.class), + eq("http_method"), any(String.class), + eq("http_path"), any(String.class) + )).thenReturn(timer); + + // when + listener.afterQuery(executionInfo, List.of(queryInfo)); + + // then + verify(meterRegistry).timer( + eq("db.query"), + eq("sql_type"), eq("SELECT"), + eq("http_method"), eq("-"), + eq("http_path"), eq("-") + ); + verify(timer).record(100L, TimeUnit.MILLISECONDS); + } + } +} diff --git a/src/test/java/com/example/solidconnection/security/filter/TokenAuthenticationFilterTest.java b/src/test/java/com/example/solidconnection/security/filter/TokenAuthenticationFilterTest.java index 36d8c3dd8..d0b7d8963 100644 --- a/src/test/java/com/example/solidconnection/security/filter/TokenAuthenticationFilterTest.java +++ b/src/test/java/com/example/solidconnection/security/filter/TokenAuthenticationFilterTest.java @@ -1,12 +1,17 @@ package com.example.solidconnection.security.filter; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.spy; import com.example.solidconnection.auth.token.config.JwtProperties; import com.example.solidconnection.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.userdetails.SiteUserDetails; import com.example.solidconnection.security.userdetails.SiteUserDetailsService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -33,6 +38,9 @@ class TokenAuthenticationFilterTest { @Autowired private JwtProperties jwtProperties; + @Autowired + private SiteUserFixture siteUserFixture; + @MockBean // 이 테스트코드에서 사용자를 조회할 필요는 없으므로 MockBean 으로 대체 private SiteUserDetailsService siteUserDetailsService; @@ -45,6 +53,11 @@ void setUp() { response = new MockHttpServletResponse(); filterChain = spy(FilterChain.class); SecurityContextHolder.clearContext(); + + SiteUser siteUser = siteUserFixture.사용자(1, "test"); + SiteUserDetails userDetails = new SiteUserDetails(siteUser); + given(siteUserDetailsService.loadUserByUsername(anyString())) + .willReturn(userDetails); } @Test @@ -61,8 +74,9 @@ void setUp() { } @Test - void 토큰이_있으면_컨텍스트에_저장한다() throws Exception { + void 토큰이_있으면_컨텍스트에_저장하고_userId를_request에_설정한다() throws Exception { // given + Long expectedUserId = 1L; Date validExpiration = new Date(System.currentTimeMillis() + 1000); String token = createTokenWithExpiration(validExpiration); request = createRequestWithToken(token); @@ -73,6 +87,7 @@ void setUp() { // then assertThat(SecurityContextHolder.getContext().getAuthentication()) .isExactlyInstanceOf(TokenAuthentication.class); + assertThat(request.getAttribute("userId")).isEqualTo(expectedUserId); then(filterChain).should().doFilter(request, response); } From 1e12904ebfc09c602a37b307e9b60d19bf81b119 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:35:15 +0900 Subject: [PATCH 30/31] =?UTF-8?q?fix:=20docker-compose=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20(#610)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: release github action 임의 실행 추가 * refactor: 기본 추천 대학 후보 추가 (#161) * fix: config.alloy 경로 수정 * hotfix: 모의지원 현황 어드민 권한 제거 * hotfix: import 제거 --------- Co-authored-by: Wibaek Park <34394229+devMuromi@users.noreply.github.com> Co-authored-by: 이세원 <107756067+leesewon00@users.noreply.github.com> Co-authored-by: Wibaek Park <34394229+wibaek@users.noreply.github.com> Co-authored-by: Yeongseo Na Co-authored-by: Wibaek Park Co-authored-by: 황규혁 <126947828+Gyuhyeok99@users.noreply.github.com> From 5129221d6e0d214391d8648c16c7c09eb918fddb Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:53:56 +0900 Subject: [PATCH 31/31] =?UTF-8?q?chore:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=ED=95=B4=EC=8B=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20(#611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/secret | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/secret b/src/main/resources/secret index 29524e2d6..1f93968a8 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit 29524e2d6dad2042400de0370a11893029aacff2 +Subproject commit 1f93968a8475d4545d90e8f681b96382d25586af