From ed2b5f17f7f68e8948420fbced36ef385be007bc Mon Sep 17 00:00:00 2001 From: Wibaek Park <34394229+wibaek@users.noreply.github.com> Date: Mon, 7 Oct 2024 22:12:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20release=20github=20action=20?= =?UTF-8?q?=EC=9E=84=EC=9D=98=20=EC=8B=A4=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac375db23..7bf1a0ddd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ name: Build Gradle and Deploy on: push: branches: [ "release" ] + workflow_dispatch: jobs: build-gradle: From 2af7b77ff8bb48bb42889f8bd695c1002a80e39f Mon Sep 17 00:00:00 2001 From: Yeongseo Na Date: Tue, 28 Jan 2025 00:24:54 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EA=B8=B0=EB=B3=B8=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EB=8C=80=ED=95=99=20=ED=9B=84=EB=B3=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../university/service/GeneralRecommendUniversities.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java b/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java index 5c1c2e787..92054eee6 100644 --- a/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java +++ b/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java @@ -31,7 +31,8 @@ public class GeneralRecommendUniversities { "오스트라바 대학", "RMIT멜버른공과대학(A형)", "알브슈타트 지그마링엔 대학", "뉴저지시티대학(A형)", "도요대학", "템플대학(A형)", "빈 공과대학교", "리스본대학 공과대학", "바덴뷔르템베르크 산학협력대학", "긴다이대학", "네바다주립대학 라스베이거스(B형)", "릴 가톨릭 대학", - "그라츠공과대학", "그라츠 대학", "코펜하겐 IT대학", "메이지대학", "분쿄가쿠인대학", "린츠 카톨릭 대학교" + "그라츠공과대학", "그라츠 대학", "코펜하겐 IT대학", "메이지대학", "분쿄가쿠인대학", "린츠 카톨릭 대학교", + "밀라노공과대학", "장물랭리옹3세대학교", "시드니대학", "아우크스부르크대학", "쳄니츠 공과대학", "북경외국어대학교 IBS" ); @Value("${university.term}") From 5dc19c355c645d5de962a059162aed2fd434740c Mon Sep 17 00:00:00 2001 From: seonghyeok Date: Tue, 26 Aug 2025 18:19:19 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20config.alloy=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev.yml | 2 +- docker-compose.prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a37ef4c55..84e50d350 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -41,6 +41,6 @@ services: - "12345:12345" volumes: - ./logs:/var/log/spring - - ./docs/config.alloy:/etc/alloy/config.alloy:ro + - ./docs/infra-conf/config.alloy:/etc/alloy/config.alloy:ro environment: - ALLOY_ENV=dev diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 30b0c9fc1..73d7f524e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -40,6 +40,6 @@ services: - "12345:12345" volumes: - ./logs:/var/log/spring - - ./docs/config.alloy:/etc/alloy/config.alloy:ro + - ./docs/infra-config/config.alloy:/etc/alloy/config.alloy:ro environment: - ALLOY_ENV=production From 99bc01be025bc5dad698c7bc25ae03a7e9a8e61c Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Tue, 26 Aug 2025 21:46:36 +0900 Subject: [PATCH 4/7] =?UTF-8?q?hotfix:=20=EB=AA=A8=EC=9D=98=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=ED=98=84=ED=99=A9=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/controller/ApplicationController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java index 07571d060..becd3f273 100644 --- a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -39,7 +39,7 @@ public ResponseEntity apply( .body(applicationSubmissionResponse); } - @RequireRoleAccess(roles = {Role.ADMIN}) + // @RequireRoleAccess(roles = {Role.ADMIN}) // todo : 추후 어드민 페이지에서 권한 변경 기능 추가 필요 @GetMapping public ResponseEntity getApplicants( @AuthorizedUser long siteUserId, From aa60ef0c1ae63bd781f617b322952e3641073945 Mon Sep 17 00:00:00 2001 From: Gyuhyeok99 <126947828+Gyuhyeok99@users.noreply.github.com> Date: Tue, 26 Aug 2025 21:48:16 +0900 Subject: [PATCH 5/7] =?UTF-8?q?hotfix:=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/controller/ApplicationController.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java index becd3f273..c388f8b25 100644 --- a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -6,8 +6,6 @@ import com.example.solidconnection.application.service.ApplicationQueryService; import com.example.solidconnection.application.service.ApplicationSubmissionService; import com.example.solidconnection.common.resolver.AuthorizedUser; -import com.example.solidconnection.security.annotation.RequireRoleAccess; -import com.example.solidconnection.siteuser.domain.Role; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; From f96997a1a2c7236d0244fffb51dca02ceb86206b Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:14:05 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[RELEASE]=20260119=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20(#609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: yml 들여쓰기 수정 (#555) * fix: yml 들여쓰기 수정 * fix: jdk 변경 * refactor: 멘토 및 채팅 관련 API 응답 수정 (#537) * 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) * refactor: CD 성능 개선 (#552) * 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으로 임시토큰 발급하도록 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 (#565) * fix: GitHub app token permission 문제 해결 (#566) * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 * fix: GitHub app token permission 문제 오류 해결 (#567) * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: GitHub app token permission이 repo 레벨에서 부여되는 문제 해결 (#568) * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 읽기 권한 명시 문법 오류 수정 * fix: Github App이 발행한 임시 토큰에 대해 Contents 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: Github App이 발행한 임시 토큰에 대해 조직 레벨에서 읽기 권한 추가 * fix: GitHub app token permission 권한 오류 해결 (#569) * 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용 설정 제거 * feat: claude.md 파일 추가 (#560) * fix : 동일 멘토 멘티 중복 신청 불가능하도록 수정 (#563) * fix : 동일 멘토 멘티 중복 신청 불가능하도록 수정 - UK 제약조건 추가 - flyway script 추가 - CustomException 추가 - Service 로직 수정 - Test code 추가 * fix : column명 오류 수정 - column명 camelCase -> snake_case로 변경 * fix : column명 오류 수정 - column명 name으로 명시 * fix: GitHub app token permission 권한 오류 해결 (#570) * 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: 필요없는 주석 제거 * fix: GHCR 정리 권한 PAT로 해결 (#573) * feat: 지원서가 APPROVED 인 유저의 멘토 생성 기능 추가 (#562) * 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 버전 충돌 해결 * feat: 어드민 멘토 승격 요청 페이징 조회 기능 추가 (#576) * 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: 기대하는 값이랑 다른 테스트 응답을 수정합니다 * feat: 어드민 멘토 승격 지원서 승인/거절 기능, 상태 별 지원서 개수 조회 기능 추가 (#577) * feat: 어드민 멘토 승격 지원서 승인/거절 기능 추가 * test: 어드민 멘토 지원서 승인/거절 테스트 추가 * feat: 멘토 지원서 상태별 개수 조회 기능 추가 * test: 멘토 지원서 상태별 개수 조회 테스트 추가 * fix: 대학이 선택되지 않은 멘토 지원서 승인 시 예외 발생하도록 수정 * refactor: 리뷰 내용 반영 * refactor: MENTOR_APPLICATION_ALREADY_CONFIRM -> MENTOR_APPLICATION_ALREADY_CONFIRMED 로 수정 * refactor: 멘토 지원서 거절 사유 관련하여 기획에 명시되지 않은 길이 제한 제거 * refactor: 리뷰 적용 * refactor: 변수명, 필드명 일관성 맞추기 * test: assertAll 적용 * feat: region 관련 관리 기능 추가 (#561) * 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 * fix: config.alloy 파일 경로 불일치 문제 해결 (#586) * fix: config.alloy 파일 경로 불일치 문제 해결 * refactor: docker-compose를 수정하는게 아닌 cd.yml의 경로를 수정하여 해결 * feat: 소셜 로그인 사용자는 비밀번호 변경을 할 수 없다. (#582) * feat: 소셜 로그인 사용자는 비밀번호 변경을 할 수 없도록 * test: 소셜 로그인 사용자 비밀번호 변경 관련 테스트 코드 작성 * chore: 컨벤션에 맞게 메서드명 변경 - ~~ 예외가 발생한다 * chore: 충돌 해결 * fix: Upgrade 헤더 유무에 따라 Connection 헤더의 값을 동적으로 설정하도록 (#581) * fix: Upgrade 헤더 유무에 따라 Connection 헤더의 값을 동적으로 설정하도록 - Upgrade 헤더가 존재하면(e.g. WebSocket) upgrade로 설정 - Upgrade 헤더가 존재하지 않으면 keep-alive로 설정 * chore: 서브모듈 업데이트 * feat: 멘토 지원서 대학교 매핑 기능, 대학 선택 상태 페이징 조건 추가 (#583) * 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 로 검증 그룹화 * refactor: 스프링 부트 앱 외의 사이드 인프라 배포 과정을 분리 (#592) * refactor: dev 환경에서의 side-infra 배포 과정 분리 * refactor: prod 환경에서의 side-infra 배포 과정 분리 * refactor: docker-compose 가 실행되고 있지 않아도 스크립트가 실패하지 않게 변경 * fix: docker compose up 시에 사용할 환경변수 중 누락된 변수를 추가 * fix: S3 이름 불일치 문제 해결 (#594) * fix: s3 이름 불일치 문제 해결 * fix: s3와의 연동된 cloudfront URL로 수정 * refactor: 분리한 사이드 인프라에 대해서 필요없는 파일 정리 (#596) * test: flyway 스크립트를 검증하는 테스트 코드 작성 (#588) * test: flyway 스크립트를 검증하는 테스트 코드 작성 * fix: DirtiesContext 어노테이션을 통해 기존 컨텍스트를 폐기하도록 - 새로운 MySQL 환경에서 마이그레이션이 이루어지도록 수정 * fix: flyway 검증용의 별도의 MySQL 컨테이너를 사용하도록 * chore: 테스트 의도를 쉽게 이해할 수 있도록 주석 추가 * chore: 명시적으로 컨테이너를 시작하도록 - 또한 MySQLTestContainer 코드와 유사한 컨벤션을 가지도록 수정 * refactor: 게시글 조회 응답에 댓글 deprecated 여부 포함하도록 (#599) * feat: 유저의 멘토 지원서 신청 이력 조회 기능 추가 (#603) * 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 을 기대하던 테스트 에러 해결 * fix: 탈퇴한 사용자가 물리적 삭제가 되지 않았던 문제를 해결한다 (#574) * refactor: FK에 ON DELETE CASCADE 옵션 추가 * refactor: 삭제 메서드로 사용자 연관 데이터를 삭제하도록 * feat: 어드민 유저 차단 기능 추가 (#604) * feat: 어드민 차단 기능 * test: 어드민 차단 기능 * feat: API 성능 로깅, 쿼리 별 메트릭 전송 추가 (#602) * 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 해결 * fix: docker-compose 충돌 해결 (#610) * chore: release github action 임의 실행 추가 * refactor: 기본 추천 대학 후보 추가 (#161) * fix: config.alloy 경로 수정 * hotfix: 모의지원 현황 어드민 권한 제거 * hotfix: import 제거 * chore: 서브모듈 해시 업데이트 (#611) --- build.gradle | 3 + .../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 ++ .../datasource/DataSourceProxyConfig.java | 29 ++ .../common/config/web/WebMvcConfig.java | 34 +++ .../common/exception/ErrorCode.java | 6 + .../common/filter/HttpLoggingFilter.java | 156 ++++++++++ .../ApiPerformanceInterceptor.java | 67 ++++ .../interceptor/BannedUserInterceptor.java | 37 +++ .../common/interceptor/RequestContext.java | 14 + .../interceptor/RequestContextHolder.java | 18 ++ .../RequestContextInterceptor.java | 36 +++ .../common/listener/QueryMetricsListener.java | 53 ++++ .../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 ++- .../filter/TokenAuthenticationFilter.java | 8 + .../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 + src/main/resources/logback-spring.xml | 106 +++++-- src/main/resources/secret | 2 +- .../service/AdminUserBanServiceTest.java | 262 ++++++++++++++++ .../common/filter/HttpLoggingFilterTest.java | 241 +++++++++++++++ .../ApiPerformanceInterceptorTest.java | 199 ++++++++++++ .../BannedUserInterceptorTest.java | 155 ++++++++++ .../RequestContextInterceptorTest.java | 112 +++++++ .../listener/QueryMetricsListenerTest.java | 289 ++++++++++++++++++ .../report/fixture/ReportFixture.java | 3 +- .../report/fixture/ReportFixtureBuilder.java | 7 + .../report/service/ReportServiceTest.java | 14 +- .../filter/TokenAuthenticationFilterTest.java | 17 +- .../siteuser/fixture/SiteUserFixture.java | 31 ++ .../fixture/SiteUserFixtureBuilder.java | 10 +- .../siteuser/fixture/UserBanFixture.java | 37 +++ .../fixture/UserBanFixtureBuilder.java | 49 +++ 48 files changed, 2376 insertions(+), 61 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/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/BannedUserInterceptor.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/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/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/BannedUserInterceptorTest.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 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/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/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/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 56bb288e8..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,19 @@ 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; @Configuration @@ -14,6 +22,10 @@ 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) { @@ -22,4 +34,26 @@ public void addArgumentResolvers(List resolvers) customPageableHandlerMethodArgumentResolver )); } + + @Override + 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/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/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/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/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/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/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/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/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/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 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/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/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/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/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/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); } 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 0280590132c935cfca3be24bc837f1f6f2b01cd7 Mon Sep 17 00:00:00 2001 From: sukangpunch Date: Thu, 22 Jan 2026 15:18:44 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20release=EC=97=90=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=ED=95=98=EB=8A=94=20DataSourceProxyConfig=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/DataSourceProxyConfig.java | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java 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 deleted file mode 100644 index b7bf0b008..000000000 --- a/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -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(); - } -}