diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a40b6a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +## 어떤 버그인가요? + +> 어떤 버그인지 간결하게 설명해주세요 + +## 어떤 상황에서 발생한 버그인가요? + +> (가능하면) Given-When-Then 형식으로 서술해주세요 + +## 예상 결과 + +> 예상했던 정상적인 결과가 어떤 것이었는지 설명해주세요 + +## 참고할만한 자료(선택) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..eaa144a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +## 어떤 기능인가요? + +> 추가하려는 기능에 대해 간결하게 설명해주세요 + +## 작업 상세 내용 + +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 참고할만한 자료(선택) diff --git a/.github/workflows/docker-multi-stage-build.yml b/.github/workflows/docker-multi-stage-build.yml new file mode 100644 index 0000000..1d7b783 --- /dev/null +++ b/.github/workflows/docker-multi-stage-build.yml @@ -0,0 +1,105 @@ +name: docker multi-stage build + +on: + pull_request: + branches: + - develop + push: + branches: + - develop + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # 모든 히스토리를 가져와서 최신 코드 보장 + + - name: Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew build -x test + + # AWS 인증 설정 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + + # ECR 로그인 + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v1 + id: login-ecr + + # Docker 이미지 빌드 및 ECR에 Push + - name: Build and Push Docker image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: game_mate + IMAGE_TAG: latest + run: | + docker build --no-cache -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + + # deploy.sh & docker-compose.yml을 EC2로 업로드 + - name: Upload deploy script and docker-compose.yml to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_KEY }} + source: "./deploy.sh, ./docker-compose.yml" + target: "/home/ubuntu/" + + # .env 파일 생성 후 EC2로 전송 + - name: Create and Upload .env file to EC2 + run: | + echo "DOCKERHUB_USERNAME=${{ vars.DOCKERHUB_USERNAME }}" > .env + echo "DOCKER_IMAGE_TAG_NAME=${{ vars.DOCKER_IMAGE_TAG_NAME }}" >> .env + echo "MYSQL_USERNAME=${{ secrets.MYSQL_USERNAME }}" >> .env + echo "MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}" >> .env + echo "MYSQL_URL=${{ secrets.MYSQL_URL }}" >> .env + echo "MYSQL_PROD_URL=${{ secrets.MYSQL_PROD_URL }}" >> .env + echo "MYSQL_DEV_URL=${{ secrets.MYSQL_DEV_URL }}" >> .env + echo "JPA_HIBERNATE_DDL=${{ secrets.JPA_HIBERNATE_DDL }}" >> .env + echo "JPA_HIBERNATE_DDL_PROD=${{ secrets.JPA_HIBERNATE_DDL_PROD }}" >> .env + echo "JPA_HIBERNATE_DDL_DEV=${{ secrets.JPA_HIBERNATE_DDL_DEV }}" >> .env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env + echo "AWS_ACCESS_KEY=${{ secrets.AWS_ACCESS_KEY }}" >> .env + echo "AWS_SECRET_KEY=${{ secrets.AWS_SECRET_KEY }}" >> .env + echo "AWS_BUCKET=${{ secrets.AWS_BUCKET }}" >> .env + echo "AWS_REGION=${{ secrets.AWS_REGION }}" >> .env + echo "AWS_STACK_AUTO=${{ secrets.AWS_STACK_AUTO }}" >> .env + echo "OAUTH2_GOOGLE_CLIENT_ID=${{ secrets.OAUTH2_GOOGLE_CLIENT_ID }}" >> .env + echo "OAUTH2_GOOGLE_CLIENT_SECRET=${{ secrets.OAUTH2_GOOGLE_CLIENT_SECRET }}" >> .env + echo "OAUTH2_KAKAO_CLIENT_ID=${{ secrets.OAUTH2_KAKAO_CLIENT_ID }}" >> .env + echo "OAUTH2_KAKAO_CLIENT_SECRET=${{ secrets.OAUTH2_KAKAO_CLIENT_SECRET }}" >> .env + echo "EMAIL_USERNAME=${{ secrets.EMAIL_USERNAME }}" >> .env + echo "EMAIL_APP_PASSWORD=${{ secrets.EMAIL_APP_PASSWORD }}" >> .env + echo "GEMINI_URL=${{ secrets.GEMINI_URL }}" >> .env + echo "GEMINI_KEY=${{ secrets.GEMINI_KEY }}" >> .env + echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" >> .env + - name: Upload .env file to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_KEY }} + source: "./.env" + target: "/home/ubuntu/" + + # EC2에서 deploy.sh 실행 (최신 Docker 이미지 가져와서 실행) + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_KEY }} + + script: | + chmod +x /home/ubuntu/deploy.sh + /home/ubuntu/deploy.sh diff --git a/.gitignore b/.gitignore index a00fec8..5526412 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ out/ .vscode/ .env +.DS_Store +**/.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..893185b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Build 스테이지 +FROM gradle:8.10.2-jdk17 AS builder + +# 작업 디렉토리 설정 +WORKDIR /apps + +# 빌더 이미지에서 애플리케이션 빌드 +COPY . /apps +#RUN gradle clean build --no-daemon --parallel +RUN gradle clean build -x test --no-daemon --parallel + +# 실행 스테이지 +# OpenJDK 17 slim 기반 이미지 사용 +FROM openjdk:17-jdk-slim + +# 이미지에 레이블 추가 +LABEL type="application" + +# 작업 디렉토리 설정 +WORKDIR /apps + +# 애플리케이션 jar 파일을 컨테이너로 복사 +COPY --from=builder /apps/build/libs/*-SNAPSHOT.jar /apps/app.jar + +# 애플리케이션이 사용할 포트 노출 +EXPOSE 8080 + +# 애플리케이션을 실행하기 위한 엔트리포인트 정의 +ENTRYPOINT ["java", "-jar", "/apps/app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index ad52b09..0166f92 100644 --- a/README.md +++ b/README.md @@ -1 +1,377 @@ -# GameMate \ No newline at end of file + +
+ +# 👀 ![image](https://github.com/user-attachments/assets/e3bfae8c-8698-4b6f-ab7a-5fe5d3ccd9e8) 👀 + +### 다양한 게임 정보와 리뷰를 공유하고 게임을 같이 할 친구를 찾을 수 있는 커뮤니티 사이트 +
+ +## 👨‍👩‍👧‍👦 Our Team + +|이예지|전수연|고강혁|양제훈| +|:---:|:---:|:---:|:---:| +|[@yeji-world](https://github.com/yeji-world)|[@sumyeom](https://github.com/sumyeom)|[@Newbiekk](https://github.com/Newbiekk-kkh)|[@89JHoon](https://github.com/89JHoon)| +|BE|BE|BE|BE| + +
+ +## 프로젝트 기능 + +### 🛡 OAuth2 소셜로그인 (kakao, google) + +> * Kakao와 google 통한 간편 로그인이 가능합니다. + +### 📧 이메일 인증 + +> * 이메일 인증 기능을 지원합니다. + + +### 👥 게임 매칭 시스템 제공 + +> * 현재 '리그오브레전드' 라는 게임에 대해 매칭 시스템을 통해 친구를 구할 수 있습니다. +> * 내 정보를 입력하고, 원하는 상대방의 조건을 입력하여 매칭 로직을 통해 최대 5인의 추천을 받을 수 있습니다. + + +### 🎮 게임 정보 확인 + +> * 다양한 게임에 대한 정보를 얻을 수 있고, 각 게임에 대해 사용자들이 작성한 리뷰를 확인 할 수 있습니다. + + +### ❗️ 게임 추천 + +> * 내가 원하는 성향을 가진 게임을 추천하는 서비스를 제공합니다. +> * Gemini API 사용해 사용자가 입력한 내용을 기반으로 게임을 추천해줍니다. + + +### 📖 게임 커뮤니티 + +> * 게임에 대한 게시글을 작성하고, 게시글에 댓글 대댓글을 작성할 수 있습니다. +> * 조회수 상위 5개의 게시글을 오늘의 게시글로 선정해 사용자에게 제공합니다. + + +### 👨‍👩‍👦 소셜 기능 + +> * 사용자간의 팔로우 기능을 제공합니다. +> * 게시물 / 게임 리뷰에 '좋아요' 또는 '싫어요'를 달 수 있습니다. + +### 🔔 실시간 알림 기능 + +> * 알림은 SSE를 통해 실시간으로 제공됩니다. +> * 매칭 / 팔로우 / 댓글 / 좋아요 등의 이벤트에 대해 알림이 제공됩니다. + +### 🎫 쿠폰 기능 + +> * 자체 쿠폰을 발급해, 사용자들에게 여러 혜택들을 제공합니다. + + +

+ + +## 📚 적용 기술 + +| 분야 | **기술 및 도구** | **목적** | +| --- | --- | --- | +| **애플리케이션 개발** | JDK 17
Spring Boot 3.4.1
IntelliJ IDEA | 1️⃣ **JDK 17**
- 성능, 보안, 개발 효율성을 위한 안정적인 운영 환경
2️⃣ **Spring Boot 3.4.1**
- 프로덕션 환경에서의 안정성과 클라우드 네이티브 기능 강화
3️⃣ **IntelliJ IDEA**
- 개발자 생산성을 극대화하는 강력한 통합 개발 환경(IDE) | +| **인증 / 인가** | Spring Security
JWT
OAuth 2.0 | 1️⃣ **Spring Security**
- 웹 애플리케이션 보안 통합 관리
2️⃣ **JWT**
- 무상태(Stateless) 인증 구현
3️⃣ **OAuth 2.0**
- 제3자 서비스의 안전한 자원 접근 관리 | +| **협업** | Git
GitHub
Slack
Notion
GitHub Project / Issue
Trello
WBS | 1️⃣ **Git & GitHub**
- 코드 협업과 버전 관리
2️⃣ **Slack & Notion**
- 진행 사항에 대해 소통하고, 문서화
3️⃣ **GitHub Project / Issue & Trello & WBS**
- 요구 사항에 대한 일정 및 우선순위 파악, 정리
- 각 일정에 대한 진행 사항 체크 | +| **Database** | RDS(MySQL)
Redis | 1️⃣ **RDS(MySQL)**
- RDS를 통해 DB를 위한 인프라 구축
- 안정적인 관계형 DB로 데이터 관리
2️⃣ **Redis**
- 게시글 조회수 카운팅을 캐시를 활용하여 효과적으로 관리
- Redisson 기반 분산 락을 통해 데이터 일관성 유지
- Redis Stream을 사용해 다중 WAS 환경에서도 알림이 유실되지 않고 전달되도록 관리 | +| **파일 첨부** | AWS S3 | 1️⃣ **AWS S3**
- 대용량 데이터를 안정적으로 유지 관리 | +| **CI/CD** | Docker
GitHub Action
AWS EC2
AWS | 1️⃣ **Docker**
- 일관된 개발 환경 제공
- 배포 및 확장이 용이
2️⃣ **GitHub Action**
- CI/CD 자동화를 구현하고, 배포 프로세스를 효율적으로 관리
3️⃣ **AWS**
- 클라우드 환경에서 안정적인 서비스 운영 | +| **외부 API** | Gemini API
Google OAuth 2.0 API
Kakao OAuth 2.0 API
JavaMail | 1️⃣ **Gemini API**
- 신뢰성 있는 게임 추천을 위한 API
2️⃣ **OAuth 2.0 API (Google & Kakao)**
- 소셜 로그인을 위한 API
3️⃣ **JavaMail**
- 메일 발송을 위한 API | + +

+ +## 📝 기술적 의사 결정 + +
+ Gemini AI 활용 방안 + +## 1. 배경 +- 게임 추천 시스템은 단순한 게임 선택을 넘어 **사용자 경험 설계**부터 **윤리적 검증**까지 종합적인 접근이 필요함. +- 플레이어의 **세션 데이터**를 지속적으로 학습에 반영하면서도, 추천의 **근거를 투명하게 제시**하는 것이 장기적 신뢰 확보의 핵심. +- AI를 이용해 게임을 추천하려면 다음 요소들을 종합적으로 분석해야 함: + - **사용자 취향** (장르, 플레이 스타일) + - **보유 기기** (PC, 콘솔, 모바일) + - **최신 트렌드** + - **소셜 데이터** (친구, 스트리머 추천) + - **추천 알고리즘** (콘텐츠 기반, 협업 필터링) + - **할인 정보, 연령 등급** 등의 요소 고려 +- 또한, **사용자 피드백을 반영**하여 지속적으로 추천 시스템을 개선하는 것이 중요함. + +## 2. 선택 이유 +- **구글의 Gemini AI**는 **멀티모달 기능**(텍스트, 이미지, 음성 등)을 지원하며, 강력한 **자연어 처리(NLP)**와 **데이터 분석 기능**을 제공. +- Gemini AI를 활용하면 단순한 게임 추천이 아니라 **사용자와 대화하며 취향을 파악하고 맞춤형 추천을 제공하는 AI 시스템**을 구축 가능. +- 특히, **Google Cloud의 API**와 결합하면 더 정교한 추천 모델을 구축할 수 있어 지속적인 발전이 기대됨. +- 결론적으로, **Gemini AI를 활용하면 더욱 정밀하고 개인화된 게임 추천 시스템 구축**을 기대할 수 있음. + +## 3. 대안 비교 +| 대안 | 장점 | 단점 | +| --- | --- | --- | +| **Microsoft Azure OpenAI + Game Pass 데이터 연계** | - **Xbox Game Pass 데이터**와 연계 가능
- **클라우드 기반 확장성** 우수 | - **Microsoft 생태계** 내에서 활용도가 높음
- **범용적인 추천 시스템 구축에는 한계** | +| **Claude (Anthropic)** | - **긴 문맥을 유지하는 대화 능력** 우수
- **친환경적이고 안전한 AI 설계** | - **게임 추천 알고리즘 구축에는 다소 한계** | +
+ +
+ AWS S3 활용 방안 + +## 1. 배경 +- 커뮤니티에서 **첨부파일(이미지) 관리**는 여러 측면에서 중요함: + - **사용자 경험(UX) 향상**: 이미지가 포함된 게시글은 가독성을 높이고 몰입감을 제공. + - **서버 성능 최적화**: 저장 공간과 로딩 속도를 고려한 최적화 필요. + - **보안 강화**: 불법 콘텐츠 필터링 및 악성 코드 방지를 위한 보안 조치 필수. + - **검색 최적화**: SEO 최적화를 통해 접근성을 높일 수 있음. + - **모바일 대응**: 다양한 네트워크 환경에서도 원활한 사용 가능. +- 효과적인 이미지 관리를 위해 **이미지 압축, AI 필터링, CDN 활용** 등의 전략이 필요하며, 이를 잘 적용하여 **원활한 커뮤니티 운영**을 목표로 함. + +## 2. 선택 이유 +1. **대용량 이미지 저장 가능** +2. **빠른 이미지 로딩 속도** + - 전 세계 어디에서든 **빠르게 로딩 가능** + - 모바일 및 저속 인터넷 환경에서도 **원활한 서비스 제공** +3. **데이터 손실 안전 및 보안 강화** + - **자동 데이터 복제**: 여러 데이터 센터에 복제 저장하여 **데이터 손실 방지** + - **접근 제어 가능**: 퍼블릭/프라이빗 설정을 통해 특정 사용자만 접근 가능 +4. **비용 절감 효과** + - **사용량 기반 과금**으로 불필요한 비용 절감 +5. **결론** + - S3를 사용하면 **이미지 저장이 편리하고, 로딩 속도가 빠르며, 데이터 손실 위험이 낮고, 보안이 뛰어나며, 비용 절감 가능** + - 특히, **많은 사용자가 이미지를 업로드하고 조회하는 커뮤니티 게시판에 적절한 솔루션** + +## 3. 대안 비교 +| 대안 | 장점 | 단점 | +| --- | --- | --- | +| **Azure Blob Storage** | - 마이크로소프트 환경과 연계 용이 | - AWS 대비 **확장성과 글로벌 커버리지 부족**
- 관리 콘솔이 다소 복잡 | +| **Firebase Storage** | - **모바일 앱과의 연동 최적화** | - **대량 이미지 저장보다는 모바일 앱 중심**
- **데이터 관리 기능이 제한적** | +
+ +
+ SSE(Server-Sent Events) 기반의 실시간 알림 시스템 + +## 1. SSE 도입 배경 +- 초기 알림 기능은 **스케줄러를 활용하여 5분마다 미확인된 알림을 이메일로 전송**하는 방식으로 구현됨. +- 그러나 이러한 방식은 **실시간성이 부족하여 즉각적인 알림 제공이 불가능**했음. +- 이를 개선하기 위해 **SSE(Server-Sent Events) 기반의 실시간 알림 시스템**을 도입하게 됨. + +## 2. SSE 선택 이유 +- 실시간 알림을 구현하기 위해 **Short Polling, Long Polling, SSE, WebSocket** 등 다양한 기술을 검토한 결과, **SSE가 가장 적합**하다고 판단됨. +- **SSE(Server-Sent Events) 장점** + ✅ **단방향(one-way) 연결**을 통해 **서버에서 클라이언트로 실시간 알림 전송** + ✅ 기존 **HTTP 프로토콜**을 활용하므로 **설정이 간편** + ✅ WebSocket과 달리 **재연결(자동 복구) 기능 내장** + ✅ 알림과 같이 **단방향 전송이 주를 이루는 서비스에 적합** + +## 3. 대안 비교 + +| 기술 | 동작 방식 | 장점 | 단점 | +| --- | --- | --- | --- | +| **Short Polling** | 클라이언트가 일정 주기마다 서버에 요청 | 구현이 간단함 | 불필요한 요청 증가로 리소스 낭비 | +| **Long Polling** | 서버가 새로운 데이터가 있을 때까지 응답을 지연 | 실시간성 개선 | 다수의 연결 유지 시 서버 부담 증가 | +| **WebSocket** | 클라이언트-서버 간 **양방향 연결 유지** | 쌍방향 통신 가능 | 설정이 복잡하며, HTTP/2 환경에서 오버헤드 증가 가능 | + +## 4. 결론 +- 알림 서비스는 **서버에서 클라이언트로 단방향 메시지 전송이 주된 역할**을 함. +- WebSocket의 **양방향 연결 기능이 불필요**하며, 구현이 간단한 **SSE가 최적의 선택**이었음. +
+
+ 알림 시스템에 Redis Stream 적용 + +## 메시지 영속성과 신뢰성 확보 + +* 기존 SSE만 사용할 경우 네트워크 문제나 서버 장애 시 알림이 유실될 수 있음 +* Redis Stream은 알림 메시지를 일시적으로 저장하고 관리하여 메시지 전달의 신뢰성을 높임 + +## 장애 상황 대응 + +* 사용자의 네트워크 연결이 끊기거나 서버에 문제가 생겼을 때도 Stream에 메시지가 보관되어 있어 재접속 시 미전송된 알림을 처리할 수 있음 + +## 시스템 확장성 + +* 향후 시스템이 확장되어 다중 WAS 환경에서 알림을 처리해야 할 경우 Redis Stream을 통해 메시지 큐 역할을 수행하여 분산 시스템에서도 안정적인 알림 처리가 가능 + +## 대안 비교 + +| 기술 | 동작 방식 | 장점 | 단점 | +|------|-----------|------|------| +| **Redis Pub/Sub** | 구독자가 있으면 실시간으로 메시지 전송 | 빠른 실시간 메시징 | 구독자가 없으면 메시지 유실 | +| **Kafka** | 로그 기반 스트리밍 | 강력한 메시지 보장, 대량 데이터 처리 가능 | 설정이 복잡하고 운영 비용이 높음 | +| **Redis Stream** | 메시지 저장 + 스트리밍 | 메시지 유실 방지, 소비자 그룹 지원 | Pub/Sub보다 약간의 설정 필요 | + +## 결론 + +알림 서비스에서는 **메시지 유실 방지가 중요**하므로, 단순 Pub/Sub보다 **Redis Stream이 적합**했습니다. Kafka는 강력한 기능을 제공하지만, 운영 복잡성과 비용 문제 및 대량 데이터 처리가 현재 레벨에서 필요하지 않을 것으로 예상되어 오버 엔지니어링 같았습니다. 이러한 이유들로 Redis Stream을 선택했습니다. +

+
+ + +## 🚨 Trouble Shooting + +
+ 게임추천- 프롬프트 관련 보안 위험 + + ## 문제인식 + +게임 추천 기능중 사용자에게 데이터를 응답 받는 중 SQL 인젝션 위험이 예상됨 + +- **보안 위험 분석** + + ```java + String prompt = String.format("...추가적인 요청은 %s 야", + userGamePreference.getExtraRequest()); + ``` + + - **문제점**: **`extraRequest`**가 프롬프트에 직접 삽입되어 악성 코드 실행 가능 + - **위험**: 프롬프트 조작을 통한 시스템 명령어 주입, 데이터 유출 등의 공격 가능성 + +- **개선 방안** + + - **OWASP 인코딩 라이브러리 적용** + + ```java + import org.owasp.encoder.Encode; + + String safeExtraRequest = Encode.forJava(extraRequest); + ``` + + - **기능**: 특수 문자 자동 이스케이프 + - **예방 공격**: XSS, 명령어 주입 + - **Bean Validation 통합** + + ```java + public class UserGamePreferenceRequestDto { + + @Size(max = 100, message = "추가 요청은 100자 이내로 입력해주세요") + @Pattern(regexp = "^[a-zA-Z0-9\\s]+$", message = "특수 문자는 사용할 수 없습니다") + private String extraRequest; + } + + public class GameRecommendContorller { + @PostMapping + public ResponseEntity createUserGamePreference( + @Valid @RequestBody UserGamePreferenceRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + ``` + + - **장점**: 선언적 검증 규칙 관리 + +- **기대 효과** + + - 입력 데이터의 무결성과 유효성이 크게 향상되어, 악의적인 데이터 주입 시도를 사전에 차단할 수 있음 + - SQL Injection과 같은 데이터베이스 공격 위험이 현저히 감소하여, 데이터베이스의 보안성이 향상 + - 사용자 경험 측면에서도 개선이 이루어져, 유효하지 않은 데이터 입력에 대한 즉각적인 피드백을 제공함으로써 사용자 친화적인 인터페이스를 구현할 수 있음 + +
+ +
+ 특정 유저의 팔로워 조회 시 성능 최적화 + + ## 🔍 기존 코드 문제점 + +```java +public List findFollowers(String email) { + User followee = userRepository.findByEmail(email) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if (followee.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } // 탈퇴한 회원 예외 처리 + + List followListByFollowee = followRepository.findByFollowee(followee); + + List followersByFollowee = followListByFollowee.stream() + .map(Follow::getFollower) + .filter(follower -> follower.getUserStatus() != UserStatus.WITHDRAW) + .toList(); + + return followersByFollowee.stream() + .map(FollowFindResponseDto::toDto) + .toList(); +} +``` + +위 코드의 **문제점은 FetchType.LAZY로 인해 N+1 문제가 발생**한다는 것입니다. + +- `Follow` 엔티티에서 `getFollower()`를 호출할 때, **각 팔로워에 대한 별도의 쿼리**가 실행됨 +- 결과적으로 팔로워가 1,000명일 경우 **총 1,002개의 쿼리**가 발생 (1개의 사용자 조회 + 1개의 팔로우 리스트 조회 + 1,000개의 팔로워 조회) + +--- + +## 💡 해결 방법: JPQL을 활용한 성능 최적화 + +이 문제를 해결하기 위해 **JPQL을 사용하여 한 번의 쿼리로 필요한 데이터를 가져오도록 수정**하였습니다. + +```java +@Query("SELECT NEW com.example.gamemate.domain.follow.dto.FollowFindResponseDto(f.follower.id, f.follower.nickname) " + + "FROM Follow f " + + "JOIN f.follower " + + "WHERE f.followee.email = :email " + + "AND f.follower.userStatus != 'WITHDRAW'") +List findFollowersDtoByFolloweeEmail(@Param("email") String email); +``` + +✅ **최적화된 코드의 장점** + +- **단 2개의 쿼리만 실행됨** → 기존 1,002개 → 2개 +- **N+1 문제 해결** → `JOIN`을 통해 한 번에 데이터 조회 +- **DTO로 직접 매핑** → 엔티티를 불필요하게 생성하지 않고 바로 DTO로 변환 + +--- + +## 📊 성능 테스트 결과 + +![image (7)](https://github.com/user-attachments/assets/0ca21e4a-33ce-4e8a-9e6c-ea3161099f99) + +| 테스트 환경 | 기존 코드 | 최적화된 코드 | 성능 개선율 | +| --- | --- | --- | --- | +| 팔로워 1,000명 기준 | **752ms** (1,002개 쿼리) | **7ms** (2개 쿼리) | **99% 성능 개선** 🚀 | + +--- + +## 🔎 결론 + +JPQL을 활용한 최적화를 통해: + +- N+1 문제를 해결하여 **실행 시간을 752ms에서 7ms로 99% 단축** +- 불필요한 쿼리를 제거하여 **DB 부하를 대폭 감소** (1,002개 → 2개) +- DTO 직접 매핑으로 **메모리 사용량 최적화** + +이러한 성능 개선을 통해 팔로워가 많은 사용자의 프로필 조회시에도 빠른 응답 속도를 보장할 수 있게 되었습니다. + +
+ +

+ + +## [📋 ERD Diagram] +## ![📋 ERD Diagram](https://github.com/user-attachments/assets/90506e5f-ecbc-4a9c-b748-02767a68140d) + + +
+ +## 🌐 Architecture + +![image (8)](https://github.com/user-attachments/assets/fa1c41cc-58e4-418d-94aa-02330e0e0ba6) + + + +## 📆 일정 관리 (WBS) +![image](https://github.com/user-attachments/assets/3cbe94f2-3236-470e-8e43-31ceacb65367) + + +
+ +## 📝 Technologies & Tools 📝 + +#### Java 17 | SpringBoot 3.4.1 | MySql 8.0 | QueryDSL 5.0 + + + + + + + + + + + +



diff --git a/build.gradle b/build.gradle index c2b577f..a316f79 100644 --- a/build.gradle +++ b/build.gradle @@ -27,9 +27,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' implementation 'at.favre.lib:bcrypt:0.10.2' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' @@ -39,6 +44,12 @@ dependencies { developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.springframework.security:spring-security-test' + + // QueryDSL + implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' @@ -46,6 +57,23 @@ dependencies { // test testImplementation 'org.mockito:mockito-core' testImplementation 'org.assertj:assertj-core' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + //S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'com.fasterxml.jackson.core:jackson-databind' + + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.0' + implementation 'com.fasterxml.jackson.core:jackson-core:2.18.0' + + // mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.1' } tasks.named('test') { diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..f7baf77 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# ECR 설정 +AWS_ACCOUNT_ID="863518453426" +REGION="ap-northeast-2" +ECR_REPOSITORY="game_mate" +IMAGE_TAG="latest" +ECR_URI="$AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$ECR_REPOSITORY" + +# AWS ECR 로그인 +aws ecr get-login-password --region $REGION | sudo docker login --username AWS --password-stdin $ECR_URI + +# 기존 컨테이너 중지 및 삭제 +if [ "$(sudo docker ps -q)" ]; then + sudo docker-compose down +fi + +# 기존 이미지 삭제 (최신 이미지 사용을 보장) +if [ "$(sudo docker images -q $ECR_URI:$IMAGE_TAG)" ]; then + sudo docker rmi -f $ECR_URI:$IMAGE_TAG +fi + +# 최신 이미지 Pull +sudo docker pull $ECR_URI:$IMAGE_TAG + +# docker-compose 실행 경로 설정 +cd /home/ubuntu + +# docker-compose 실행 +sudo docker-compose -f docker-compose.yml up -d --force-recreate diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..09b0ea7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + app: + image: "863518453426.dkr.ecr.ap-northeast-2.amazonaws.com/game_mate:latest" + platform: linux/amd64 + container_name: app + ports: + - "8080:8080" + environment: + MYSQL_USERNAME: "${MYSQL_USERNAME}" + MYSQL_PASSWORD: "${MYSQL_PASSWORD}" + MYSQL_URL: "${MYSQL_URL}" + MYSQL_PROD_URL: "${MYSQL_PROD_URL}" + MYSQL_DEV_URL: "${MYSQL_DEV_URL}" + JPA_HIBERNATE_DDL: "${JPA_HIBERNATE_DDL}" + JPA_HIBERNATE_DDL_PROD: "${JPA_HIBERNATE_DDL_PROD}" + JPA_HIBERNATE_DDL_DEV: "${JPA_HIBERNATE_DDL_DEV}" + JWT_SECRET: "${JWT_SECRET}" + AWS_ACCESS_KEY: "${AWS_ACCESS_KEY}" + AWS_SECRET_KEY: "${AWS_SECRET_KEY}" + AWS_BUCKET: "${AWS_BUCKET}" + AWS_REGION: "${AWS_REGION}" + AWS_STACK_AUTO: "${AWS_STACK_AUTO}" + OAUTH2_GOOGLE_CLIENT_ID: "${OAUTH2_GOOGLE_CLIENT_ID}" + OAUTH2_GOOGLE_CLIENT_SECRET: "${OAUTH2_GOOGLE_CLIENT_SECRET}" + OAUTH2_KAKAO_CLIENT_ID : "${OAUTH2_KAKAO_CLIENT_ID}" + OAUTH2_KAKAO_CLIENT_SECRET: "${OAUTH2_KAKAO_CLIENT_SECRET}" + EMAIL_USERNAME: "${EMAIL_USERNAME}" + EMAIL_APP_PASSWORD: "${EMAIL_APP_PASSWORD}" + GEMINI_URL: "${GEMINI_URL}" + GEMINI_KEY: "${GEMINI_KEY}" + REDIS_HOST: "${REDIS_HOST}" + + diff --git a/keys b/keys new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/example/gamemate/GameMateApplication.java b/src/main/java/com/example/gamemate/GameMateApplication.java index 80b6a71..77c5470 100644 --- a/src/main/java/com/example/gamemate/GameMateApplication.java +++ b/src/main/java/com/example/gamemate/GameMateApplication.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableJpaAuditing +@EnableScheduling public class GameMateApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/gamemate/domain/auth/controller/AuthController.java b/src/main/java/com/example/gamemate/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..ed65594 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/controller/AuthController.java @@ -0,0 +1,85 @@ +package com.example.gamemate.domain.auth.controller; + +import com.example.gamemate.domain.auth.dto.*; +import com.example.gamemate.domain.auth.service.AuthService; +import com.example.gamemate.domain.auth.service.EmailService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final EmailService emailService; + + @PostMapping("/signup") + public ResponseEntity signup( + @Valid @RequestBody SignupRequestDto requestDto + ) { + SignupResponseDto responseDto = authService.signup(requestDto); + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + @PostMapping("/email/verification-request") + public ResponseEntity sendVerificationEmail( + @Valid @RequestBody EmailVerificationCodeRequestDto requestDto + ) { + emailService.sendVerificationEmail(requestDto.getEmail()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @PostMapping("/email/verify") + public ResponseEntity verifyEmail( + @Valid @RequestBody EamilVerifyRequestDto requestDto + ) { + emailService.verifyEmail(requestDto.getEmail(), requestDto.getCode()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + + @PostMapping("/login") + public ResponseEntity localLogin( + @Valid @RequestBody LocalLoginRequestDto requestDto, + HttpServletResponse response + ) { + LoginTokenResponseDto responseDto = authService.localLogin(requestDto, response); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + @PostMapping("/oauth2/set-password") + public ResponseEntity setPassword( + @Valid @RequestBody OAuth2PasswordSetRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + authService.setOAuth2Password(customUserDetails.getUser(), requestDto.getPassword()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + HttpServletRequest request, + HttpServletResponse response + ) { + authService.logout(customUserDetails.getUser(), request, response); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @PostMapping("/refresh") + public ResponseEntity refreshToken( + @CookieValue(name = "refresh_token") String refreshToken + ) { + TokenRefreshResponseDto responseDto = authService.refreshAccessToken(refreshToken); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/EamilVerifyRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/EamilVerifyRequestDto.java new file mode 100644 index 0000000..2149b02 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/EamilVerifyRequestDto.java @@ -0,0 +1,19 @@ +package com.example.gamemate.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class EamilVerifyRequestDto { + + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식을 확인해주세요.") + private final String email; + + @NotBlank(message = "인증 코드를 입력해주세요.") + private final String code; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/EmailVerificationCodeRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/EmailVerificationCodeRequestDto.java new file mode 100644 index 0000000..9993ec7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/EmailVerificationCodeRequestDto.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class EmailVerificationCodeRequestDto { + + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식을 확인해주세요.") + private final String email; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/LocalLoginRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/LocalLoginRequestDto.java new file mode 100644 index 0000000..885417e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/LocalLoginRequestDto.java @@ -0,0 +1,17 @@ +package com.example.gamemate.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class LocalLoginRequestDto { + + @NotBlank(message = "이메일을 입력해주세요.") + private final String email; + + @NotBlank(message = "비밀번호를 입력해주세요.") + private final String password; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/LoginTokenResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/LoginTokenResponseDto.java new file mode 100644 index 0000000..21d178f --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/LoginTokenResponseDto.java @@ -0,0 +1,12 @@ +package com.example.gamemate.domain.auth.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class LoginTokenResponseDto { + + private final String accessToken; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2LoginResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2LoginResponseDto.java new file mode 100644 index 0000000..aa79043 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2LoginResponseDto.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.auth.dto; + +import com.example.gamemate.domain.user.enums.AuthProvider; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class OAuth2LoginResponseDto { + + private final String providerId; + private final String email; + private final String name; + private final AuthProvider provider; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2PasswordSetRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2PasswordSetRequestDto.java new file mode 100644 index 0000000..299fbdb --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/OAuth2PasswordSetRequestDto.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class OAuth2PasswordSetRequestDto { + + @NotBlank(message = "비밀번호를 입력해주세요.") +// @Size(min = 8, message = "비밀번호는 8글자 이상으로 입력해주세요.") +// @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*[!?@#$%^&*+=-])(?=.*\\d).{8,15}$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자 최소 1글자씩 입력해주세요.") + private final String password; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/OAuthUrlResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/OAuthUrlResponseDto.java new file mode 100644 index 0000000..791be67 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/OAuthUrlResponseDto.java @@ -0,0 +1,12 @@ +package com.example.gamemate.domain.auth.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class OAuthUrlResponseDto { + + private final String authorizationUrl; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/SignupRequestDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/SignupRequestDto.java new file mode 100644 index 0000000..6b6c242 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/SignupRequestDto.java @@ -0,0 +1,31 @@ +package com.example.gamemate.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class SignupRequestDto { + + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식을 확인해주세요.") + private final String email; + + @NotBlank(message = "이름을 입력해주세요.") + private final String name; + + @NotBlank(message = "닉네임을 입력해주세요.") + private final String nickname; + + @NotBlank(message = "비밀번호를 입력해주세요.") +// @Size(min = 8, message = "비밀번호는 8글자 이상으로 입력해주세요.") +// @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자 최소 1글자씩 입력해주세요.") + private final String password; + + @NotNull(message = "이메일 인증이 필요합니다.") + private final Boolean isEmailVerified; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/SignupResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/SignupResponseDto.java new file mode 100644 index 0000000..fe95d7b --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/SignupResponseDto.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.auth.dto; + +import com.example.gamemate.domain.user.entity.User; +import lombok.Getter; + +@Getter +public class SignupResponseDto { + + private final Long id; + private final String email; + private final String name; + private final String nickname; + + public SignupResponseDto(User user) { + this.id = user.getId(); + this.email = user.getEmail(); + this.name = user.getName(); + this.nickname = user.getNickname(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/auth/dto/TokenRefreshResponseDto.java b/src/main/java/com/example/gamemate/domain/auth/dto/TokenRefreshResponseDto.java new file mode 100644 index 0000000..a20b917 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/dto/TokenRefreshResponseDto.java @@ -0,0 +1,12 @@ +package com.example.gamemate.domain.auth.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class TokenRefreshResponseDto { + + private final String accessToken; + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/service/AuthService.java b/src/main/java/com/example/gamemate/domain/auth/service/AuthService.java new file mode 100644 index 0000000..2f8e6be --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/service/AuthService.java @@ -0,0 +1,128 @@ +package com.example.gamemate.domain.auth.service; + +import com.example.gamemate.domain.auth.dto.*; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.AuthProvider; +import com.example.gamemate.domain.user.enums.UserStatus; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import com.example.gamemate.global.provider.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final TokenService tokenService; + private final RefreshTokenService refreshTokenService; + private final JwtTokenProvider jwtTokenProvider; + private final EmailService emailService; + + public SignupResponseDto signup(SignupRequestDto requestDto) { + // 기존 사용자 중복 체크 + Optional findUser = userRepository.findByEmail(requestDto.getEmail()); + + if(findUser.isPresent()) { + if(findUser.get().getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } + throw new ApiException(ErrorCode.DUPLICATE_EMAIL); + } + + // 이메일 인증 여부 확인 + if (!emailService.isEmailVerified(requestDto.getEmail())) { + throw new ApiException(ErrorCode.EMAIL_NOT_VERIFIED); + } + + // 비밀번호 암호화 + String rawPassword = requestDto.getPassword(); + String encodedPassword = passwordEncoder.encode(rawPassword); + + // 새로운 사용자 생성 및 저장 + User newUser = new User(requestDto.getEmail(), requestDto.getName(), requestDto.getNickname(), encodedPassword); + User savedUser = userRepository.save(newUser); + + return new SignupResponseDto(savedUser); + } + + public LoginTokenResponseDto localLogin(LocalLoginRequestDto requestDto, HttpServletResponse response) { + + User findUser = userRepository.findByEmail(requestDto.getEmail()) + .orElseThrow(()-> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if(findUser.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } + + if (findUser.getPassword().equals("OAUTH2_USER")) { + throw new ApiException(ErrorCode.SOCIAL_PASSWORD_REQUIRED); + } + + if(!passwordEncoder.matches(requestDto.getPassword(), findUser.getPassword())) { + throw new ApiException(ErrorCode.INVALID_PASSWORD); + } + + findUser.updateModifiedAt(); + + return tokenService.generateLoginTokens(findUser, response); + } + + public void setOAuth2Password(User user, String password) { + if(user.getProvider() == AuthProvider.LOCAL) { + throw new ApiException(ErrorCode.SOCIAL_PASSWORD_FORBIDDEN); + } + + if(!"OAUTH2_USER".equals(user.getPassword())) { + throw new ApiException(ErrorCode.SOCIAL_PASSWORD_ALREADY_SET); + } + + String encodedPassword = passwordEncoder.encode(password); + user.updatePassword(encodedPassword); + userRepository.save(user); + } + + public TokenRefreshResponseDto refreshAccessToken(String refreshToken) { + if(!jwtTokenProvider.validateToken(refreshToken)) { + throw new ApiException(ErrorCode.INVALID_TOKEN); + } + + String email = jwtTokenProvider.getEmailFromToken(refreshToken); + String storedToken = refreshTokenService.getRefreshToken(email); + + if(!refreshToken.equals(storedToken)) { + throw new ApiException(ErrorCode.INVALID_TOKEN); + } + + User user = userRepository.findByEmail(email) + .orElseThrow(()-> new ApiException(ErrorCode.USER_NOT_FOUND)); + + String newAccessToken = jwtTokenProvider.createAccessToken(email, user.getRole()); + return new TokenRefreshResponseDto(newAccessToken); + } + + public void logout(User user, HttpServletRequest request, HttpServletResponse response) { + // 엑세스 토큰 블랙리스트 추가 + String accessToken = tokenService.extractToken(request); + if(accessToken != null) { + tokenService.blacklistToken(accessToken); + } + + // 리프레시 토큰 처리 + String refreshToken = refreshTokenService.extractRefreshTokenFromCookie(request); + if(refreshToken != null) { + refreshTokenService.removeRefreshToken(user.getEmail(), response); + } + } +} diff --git a/src/main/java/com/example/gamemate/domain/auth/service/EmailService.java b/src/main/java/com/example/gamemate/domain/auth/service/EmailService.java new file mode 100644 index 0000000..7ce3372 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/service/EmailService.java @@ -0,0 +1,149 @@ +package com.example.gamemate.domain.auth.service; + +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +@Transactional +public class EmailService { + + private static final long VERIFICATION_TIME_LIMIT = 5; //5분 + private final JavaMailSender emailSender; + private final UserRepository userRepository; + private final Map verificationMap = new ConcurrentHashMap<>(); + + public void sendVerificationEmail(String email) { + // 이미 가입된 이메일인지 확인 + if (userRepository.findByEmail(email).isPresent()) { + throw new ApiException(ErrorCode.DUPLICATE_EMAIL); + } + + String verificationCode = generateVerificationCode(); + + try { + MimeMessage message = createEmailMessage(email, verificationCode); + emailSender.send(message); + + // 메모리에 인증 정보 저장 + // Todo: 추후 Redis로 수정 예정 + verificationMap.put(email, new VerificationInfo( + verificationCode, + LocalDateTime.now().plusMinutes(VERIFICATION_TIME_LIMIT) + )); + } catch (MailException e) { + throw new ApiException(ErrorCode.EMAIL_SEND_ERROR); + } + } + + public boolean verifyEmail(String email, String code) { + // 인증 정보 확인 + VerificationInfo verificationInfo = getVerificationInfo(email); + + //인증 정보가 없는 경우 + if (verificationInfo == null) { + throw new ApiException(ErrorCode.VERIFICATION_NOT_FOUND); + } + + // 인증 정보가 만료된 경우 + if (LocalDateTime.now().isAfter(verificationInfo.getExpiryTime())) { + verificationMap.remove(email); + throw new ApiException(ErrorCode.VERIFICATION_TIME_EXPIRED); + } + + // 인증 코드 불일치 + if (!verificationInfo.getCode().equals(code)) { + throw new ApiException(ErrorCode.INVALID_VERIFICATION_CODE); + } + + // 인증 성공 시 상태 변경 + verificationInfo.markAsVerified(); + verificationMap.put(email, verificationInfo); + + // 만료된 모든 인증 정보 제거 + verificationMap.entrySet().removeIf(entry -> + LocalDateTime.now().isAfter(entry.getValue().getExpiryTime()) + ); + + return true; + } + + public boolean isEmailVerified(String email) { + VerificationInfo info = verificationMap.get(email); + + return info != null + && !LocalDateTime.now().isAfter(info.getExpiryTime()) + && info.isVerified(); + } + + // Todo: 추후 Redis로 수정 예정 + private VerificationInfo getVerificationInfo(String email) { + VerificationInfo info = verificationMap.get(email); + + if (info != null && LocalDateTime.now().isAfter(info.getExpiryTime())) { + verificationMap.remove(email); + return null; + } + + return info; + } + + private String generateVerificationCode() { + return UUID.randomUUID().toString().substring(0, 8); + } + + private MimeMessage createEmailMessage(String email, String code) { + try { + MimeMessage message = emailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom("lee24pm@gmail.com"); + helper.setTo(email); + helper.setSubject("[GameMate] 이메일 인증"); + helper.setText(createEmailContent(code), true); + + return message; + } catch (MessagingException e) { + throw new ApiException(ErrorCode.EMAIL_SEND_ERROR); + } + } + + private String createEmailContent(String code) { + return String.format( + "
" + + "

GameMate 이메일 인증

" + + "
" + + "

아래 인증 코드를 입력해주세요.

" + + "
" + + "
인증 코드 : %s
" + + "
", code); + } + + @Getter + @RequiredArgsConstructor + private static class VerificationInfo { + private final String code; + private final LocalDateTime expiryTime; + private boolean verified = false; + + public void markAsVerified() { + this.verified = true; + } + } + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/service/OAuth2Service.java b/src/main/java/com/example/gamemate/domain/auth/service/OAuth2Service.java new file mode 100644 index 0000000..438f7a1 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/service/OAuth2Service.java @@ -0,0 +1,162 @@ +package com.example.gamemate.domain.auth.service; + +import com.example.gamemate.domain.auth.dto.OAuth2LoginResponseDto; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.AuthProvider; +import com.example.gamemate.domain.user.enums.UserStatus; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class OAuth2Service { + + private final UserRepository userRepository; + + public OAuth2LoginResponseDto extractOAuth2Attributes(AuthProvider provider, Map attributes) { + if(provider == AuthProvider.GOOGLE) { + return extractGoogleAttributes(attributes); + } else if(provider == AuthProvider.KAKAO) { + return extractKakaoAttributes(attributes); + } + throw new ApiException(ErrorCode.INVALID_PROVIDER_TYPE); + } + + public User processOAuth2User(OAuth2LoginResponseDto responseDto) { + // 기존 사용자 조회 + Optional findUser = userRepository.findByEmail(responseDto.getEmail()); + + // 기존 사용자 존재하는 경우 + if (findUser.isPresent()) { + User existingUser = findUser.get(); + + // 탈퇴한 사용자 체크 + if (existingUser.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } + + // 이메일로 가입한 계정이 소셜 로그인 시도한 경우 + if (existingUser.getProvider() == AuthProvider.LOCAL) { + existingUser.integrateOAuthProvider(responseDto.getProvider(), responseDto.getProviderId()); + return userRepository.save(existingUser); + } + + // 다른 OAuth 제공자로 로그인 시도한 경우 + if (!existingUser.getProvider().equals(responseDto.getProvider())) { + throw new ApiException(ErrorCode.INVALID_PROVIDER_TYPE); + } + + return existingUser; + } + + // 새로운 사용자 생성 + User newUser = new User( + responseDto.getEmail(), + responseDto.getName(), + responseDto.getName(), + responseDto.getProvider(), + responseDto.getProviderId() + ); + return userRepository.save(newUser); + } + +// public String generateAuthorizationUrl(String providerName, String redirectUri) { +// +// AuthProvider provider = AuthProvider.fromString(providerName); +// ClientRegistration clientRegistration = getClientRegistration(provider); +// String state = UUID.randomUUID().toString(); +// log.info("Generated OAuth2 State for provider {}: {}", providerName, state); +// +// String authorizationUrl = UriComponentsBuilder +// .fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) +// .queryParam("client_id", clientRegistration.getClientId()) +// .queryParam("redirect_uri", redirectUri != null ? redirectUri : clientRegistration.getRedirectUri()) +// .queryParam("response_type", "code") +// .queryParam("scope", String.join(" ", clientRegistration.getScopes())) +// .queryParam("state", state) +// .build() +// .toUriString(); +// log.info("Generated Authorization URL for provider {}: {}", providerName, authorizationUrl); +// return authorizationUrl; +// } + +// private ClientRegistration getClientRegistration(AuthProvider provider) { +// String registrationId = provider.name().toLowerCase(); +// +// OAuth2ClientProperties.Registration registration = +// clientProperties.getRegistration().get(registrationId); +// OAuth2ClientProperties.Provider providerConfig = +// clientProperties.getProvider().get(registrationId); +// +// if (registration == null || providerConfig == null) { +// throw new ApiException(ErrorCode.INVALID_PROVIDER_TYPE); +// } +// +// return ClientRegistration.withRegistrationId(registrationId) +// .clientId(registration.getClientId()) +// .clientSecret(registration.getClientSecret()) +// .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) +// .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) +// .redirectUri(registration.getRedirectUri()) +// .scope(registration.getScope()) +// .authorizationUri(providerConfig.getAuthorizationUri()) +// .tokenUri(providerConfig.getTokenUri()) +// .userInfoUri(providerConfig.getUserInfoUri()) +// .userNameAttributeName(providerConfig.getUserNameAttribute()) +// .clientName(registrationId) +// .build(); +// } + + + private OAuth2LoginResponseDto extractGoogleAttributes(Map attributes) { + return new OAuth2LoginResponseDto( + getSafeString(attributes.get("sub")), + getSafeString(attributes.get("email")), + getSafeString(attributes.get("name")), + AuthProvider.GOOGLE + ); + } + + private OAuth2LoginResponseDto extractKakaoAttributes(Map attributes) { + String providerId = getSafeString(attributes.get("id")); + + Map kakaoAccount = getSafeMap(attributes, "kakao_account"); + Map profile = getSafeMap(kakaoAccount, "profile"); + + return new OAuth2LoginResponseDto( + providerId, + getSafeString(kakaoAccount.get("email")), + getSafeString(profile.get("nickname")), + AuthProvider.KAKAO + ); + } + + private String getSafeString(Object obj) { + if (obj == null) { + throw new ApiException(ErrorCode.INVALID_OAUTH2_ATTRIBUTE); + } + return obj.toString(); + } + + private Map getSafeMap(Map attributes, String attributeName) { + Object attributeValue = attributes.get(attributeName); + // instanceof 검사를 통해 타입 안정성 확보, null 체크 포함 + if (!(attributeValue instanceof Map)) { + throw new ApiException(ErrorCode.INVALID_OAUTH2_ATTRIBUTE); + } + return (Map) attributeValue; + } + +} diff --git a/src/main/java/com/example/gamemate/domain/auth/service/RefreshTokenService.java b/src/main/java/com/example/gamemate/domain/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..890884b --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/service/RefreshTokenService.java @@ -0,0 +1,75 @@ +package com.example.gamemate.domain.auth.service; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; + +@Service +@Transactional +public class RefreshTokenService { + + private final StringRedisTemplate refreshTokenRedisTemplate; + private final Duration REFRESH_TOKEN_TTL = Duration.ofDays(7); + private int refreshTokenMaxAge = 60 * 60 * 24 * 7; // 7일 + + public RefreshTokenService( + @Qualifier("refreshTokenRedisTemplate") StringRedisTemplate refreshTokenRedisTemplate + ) { + this.refreshTokenRedisTemplate = refreshTokenRedisTemplate; + } + + public void saveRefreshToken(String email, String refreshToken, HttpServletResponse response) { + String key = getKey(email); + refreshTokenRedisTemplate.opsForValue().set(key, refreshToken, REFRESH_TOKEN_TTL); + addRefreshTokenToCookie(response, refreshToken); + } + + public String getRefreshToken(String email) { + String key = getKey(email); + return refreshTokenRedisTemplate.opsForValue().get(key); + } + + public void removeRefreshToken(String email, HttpServletResponse response) { + String key = getKey(email); + refreshTokenRedisTemplate.delete(key); + removeRefreshTokenCookie(response); + } + + private String getKey(String email) { + return "refresh_token:" + email; + } + + private void addRefreshTokenToCookie(HttpServletResponse response, String refreshToken) { + Cookie cookie = new Cookie("refresh_token", refreshToken); + cookie.setHttpOnly(true); // 자바 스크립트에서 접근 불가 + cookie.setSecure(true); // HTTPS에서만 동작 + cookie.setPath("/"); // 모든 경로에서 유효 + cookie.setMaxAge(refreshTokenMaxAge); + response.addCookie(cookie); // 쿠키를 응답에 추가 + } + + public void removeRefreshTokenCookie(HttpServletResponse response) { + Cookie cookie = new Cookie("refresh_token", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + } + + public String extractRefreshTokenFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("refresh_token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } +} diff --git a/src/main/java/com/example/gamemate/domain/auth/service/TokenService.java b/src/main/java/com/example/gamemate/domain/auth/service/TokenService.java new file mode 100644 index 0000000..21fce38 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/auth/service/TokenService.java @@ -0,0 +1,69 @@ +package com.example.gamemate.domain.auth.service; + +import com.example.gamemate.domain.auth.dto.LoginTokenResponseDto; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.provider.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.Duration; + +@Service +@Transactional +public class TokenService { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + private final StringRedisTemplate tokenBlacklistRedisTemplate; + + public TokenService( + JwtTokenProvider jwtTokenProvider, + RefreshTokenService refreshTokenService, + @Qualifier("blacklistRedisTemplate") StringRedisTemplate tokenBlacklistRedisTemplate) { + this.jwtTokenProvider = jwtTokenProvider; + this.refreshTokenService = refreshTokenService; + this.tokenBlacklistRedisTemplate = tokenBlacklistRedisTemplate; + } + + public LoginTokenResponseDto generateLoginTokens(User user, HttpServletResponse response) { + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRole()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail()); + + refreshTokenService.saveRefreshToken(user.getEmail(), refreshToken, response); + return new LoginTokenResponseDto(accessToken); + } + + public void blacklistToken(String token) { + long expirationTime = jwtTokenProvider.getExpirationFromToken(token); + Duration ttl = Duration.ofMillis(expirationTime - System.currentTimeMillis()); + if (!ttl.isNegative()) { + tokenBlacklistRedisTemplate.opsForValue().set(getBlacklistKey(token), "1", ttl); + } + } + + public boolean validateToken(String token) { + return !isBlacklisted(token) && jwtTokenProvider.validateToken(token); + } + + public String extractToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + private boolean isBlacklisted(String token) { + return Boolean.TRUE.equals(tokenBlacklistRedisTemplate.hasKey(getBlacklistKey(token))); + } + + private String getBlacklistKey(String token) { + return "blacklist:" + token; + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/controller/BoardController.java b/src/main/java/com/example/gamemate/domain/board/controller/BoardController.java new file mode 100644 index 0000000..f88bdcb --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/controller/BoardController.java @@ -0,0 +1,157 @@ +package com.example.gamemate.domain.board.controller; + +import com.example.gamemate.domain.board.dto.BoardRequestDto; +import com.example.gamemate.domain.board.dto.BoardResponseDto; +import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto; +import com.example.gamemate.domain.board.dto.BoardFindOneResponseDto; +import com.example.gamemate.domain.board.enums.BoardCategory; +import com.example.gamemate.domain.board.service.BoardService; +import com.example.gamemate.domain.board.service.BoardViewService; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 게시글 관련 API를 처리하는 컨트롤러 클래스입니다. + * 게시글의 생성, 조회, 수정, 삭제 기능을 제공합니다. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/boards") +public class BoardController { + + private final BoardService boardService; + private final BoardViewService boardViewService; + + /** + * 게시글 생성 API 입니다. + * + * @param dto 게시글 생성 dto + * @param customUserDetails 인증 정보 + * @return 생성된 게시글 정보를 포함한 ResponseEntity + */ + @PostMapping + public ResponseEntity createBoard( + @Valid @RequestBody BoardRequestDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + + BoardResponseDto responseDto = boardService.createBoard(customUserDetails.getUser(), dto); + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + + /** + * 게시글 조회하고 검색하는 API 입니다. + * + * @param category 카테고리 종류 + * @return 게시글 목록을 포함한 ResponseEntity + */ + @GetMapping("/rankings") + public ResponseEntity> findTopBoards( + @RequestParam(required = false) String category + ) { + + BoardCategory boardCategory = null; + if (category != null) { + boardCategory = BoardCategory.fromName(category); + } + + List dtos = boardViewService.findTopBoards(boardCategory); + if(dtos.isEmpty()){ + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + return new ResponseEntity<>(dtos, HttpStatus.OK); + } + + /** + * 게시글 조회하고 검색하는 API 입니다. + * + * @param page 페이지 번호(기본값 : 0) + * @param category 카테고리 종류 + * @param title 게시글 제목 + * @param content 게시글 내용 + * @return 게시글 목록을 포함한 ResponseEntity + */ + @GetMapping + public ResponseEntity> findAllBoards( + @RequestParam(defaultValue = "0") int page, + @RequestParam(required = false) String category, + @RequestParam(required = false) String title, + @RequestParam(required = false) String content + ) { + + BoardCategory boardCategory = null; + if (category != null) { + boardCategory = BoardCategory.fromName(category); + } + + List dtos = boardService.findAllBoards(page,boardCategory,title,content); + if(dtos.isEmpty()){ + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + return new ResponseEntity<>(dtos, HttpStatus.OK); + } + + /** + * 게시글 단건 조회하는 API 입니다. + * + * @param id 게시글 식별자 + * @return 게시글 ResponseEntity + */ + @GetMapping("/{id}") + public ResponseEntity findBoardById( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + + User loginUser = customUserDetails != null ? customUserDetails.getUser() : null; + + BoardFindOneResponseDto dto = boardService.findBoardById(id, loginUser); + return new ResponseEntity<>(dto, HttpStatus.OK); + } + + + /** + * 게시글 업데이트하는 API 입니다. + * + * @param id 게시글 식별자 + * @param dto 게시글 업데이트 dto + * @param customUserDetails 인증 정보 + * @return Void ResponseEntity + */ + @PatchMapping("/{id}") + public ResponseEntity updateBoard( + @PathVariable Long id, + @Valid @RequestBody BoardRequestDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + + boardService.updateBoard(customUserDetails.getUser(), id, dto); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 게시글 삭제하는 API 입니다. + * + * @param id 게시글 식별자 + * @param customUserDetails 인증 정보 + * @return Void ResponseEntity + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteBoard( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + + boardService.deleteBoard(customUserDetails.getUser(), id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardFindAllResponseDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindAllResponseDto.java new file mode 100644 index 0000000..f682af3 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindAllResponseDto.java @@ -0,0 +1,32 @@ +package com.example.gamemate.domain.board.dto; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.board.enums.BoardCategory; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class BoardFindAllResponseDto { + private final Long id; + private final BoardCategory category; + private final String title; + private final LocalDateTime createdAt; + private final int views; + + public BoardFindAllResponseDto(Long id, BoardCategory category, String title, LocalDateTime createdAt, int views) { + this.id = id; + this.category = category; + this.title = title; + this.createdAt = createdAt; + this.views = views; + } + + public BoardFindAllResponseDto(Board board) { + this.id = board.getId(); + this.category = board.getCategory(); + this.title = board.getTitle(); + this.createdAt = board.getCreatedAt(); + this.views = board.getViews(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardFindOneResponseDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindOneResponseDto.java new file mode 100644 index 0000000..1222f69 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardFindOneResponseDto.java @@ -0,0 +1,54 @@ +package com.example.gamemate.domain.board.dto; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.board.enums.BoardCategory; +import com.example.gamemate.domain.boardImage.entity.BoardImage; +import com.example.gamemate.domain.comment.dto.CommentFindResponseDto; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class BoardFindOneResponseDto { + private final Long id; + private final BoardCategory category; + private final String title; + private final String content; + private final String nickname; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; + private final List fileName; + private final List imageUrl; + + public BoardFindOneResponseDto(Long id, BoardCategory category, String title, String content, String nickname, LocalDateTime createdAt, LocalDateTime modifiedAt, List fileName, List imageUrl) { + this.id = id; + this.category = category; + this.title = title; + this.content = content; + this.nickname = nickname; + this.createdAt = createdAt; + this.modifiedAt = modifiedAt; + this.fileName = fileName; + this.imageUrl = imageUrl; + } + + public BoardFindOneResponseDto(Board board) { + this.id = board.getId(); + this.category = board.getCategory(); + this.title = board.getTitle(); + this.content = board.getContent(); + this.nickname = board.getUser().getNickname(); + this.createdAt = board.getCreatedAt(); + this.modifiedAt = board.getModifiedAt(); + this.fileName = board.getBoardImages().isEmpty() ? null : + board.getBoardImages().stream() + .map(BoardImage::getFileName) + .toList(); + this.imageUrl = board.getBoardImages().isEmpty() ? null : + board.getBoardImages().stream() + .map(BoardImage::getFilePath) + .toList(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardRequestDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardRequestDto.java new file mode 100644 index 0000000..8046759 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardRequestDto.java @@ -0,0 +1,23 @@ +package com.example.gamemate.domain.board.dto; + +import com.example.gamemate.domain.board.enums.BoardCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class BoardRequestDto { + @NotNull(message = "카테고리를 선택하세요.") + private final BoardCategory category; + @NotBlank(message = "제목을 입력하세요.") + private final String title; + @NotBlank(message = "내용을 입력하세요.") + private final String content; + + + public BoardRequestDto(BoardCategory category, String title, String content) { + this.category = category; + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/dto/BoardResponseDto.java b/src/main/java/com/example/gamemate/domain/board/dto/BoardResponseDto.java new file mode 100644 index 0000000..b7465d0 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/dto/BoardResponseDto.java @@ -0,0 +1,27 @@ +package com.example.gamemate.domain.board.dto; + +import com.example.gamemate.domain.board.enums.BoardCategory; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class BoardResponseDto { + private final Long id; + private final BoardCategory category; + private final String title; + private final String content; + private final String nickname; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public BoardResponseDto(Long id, BoardCategory category, String title, String content, String nickname, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.category = category; + this.title = title; + this.content = content; + this.nickname = nickname; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/entity/Board.java b/src/main/java/com/example/gamemate/domain/board/entity/Board.java new file mode 100644 index 0000000..c15f364 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/entity/Board.java @@ -0,0 +1,57 @@ +package com.example.gamemate.domain.board.entity; + +import com.example.gamemate.domain.board.enums.BoardCategory; +import com.example.gamemate.domain.boardImage.entity.BoardImage; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "board") +public class Board extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private BoardCategory category; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + private int views = 0; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "board") + private List boardImages; + + public Board(BoardCategory category, String title, String content, User user) { + this.category = category; + this.title = title; + this.content = content; + this.user = user; + } + + public void updateBoard(BoardCategory category, String title, String content) { + this.category = category; + this.title = title; + this.content = content; + } + + public void updateViewCount(int views){ + this.views=views; + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/enums/BoardCategory.java b/src/main/java/com/example/gamemate/domain/board/enums/BoardCategory.java new file mode 100644 index 0000000..9b5eda4 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/enums/BoardCategory.java @@ -0,0 +1,37 @@ +package com.example.gamemate.domain.board.enums; + +import com.example.gamemate.global.exception.ApiException; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static com.example.gamemate.global.constant.ErrorCode.INVALID_INPUT; + +@Getter +public enum BoardCategory { + FREE("자유_게시판"), + INFO("정보_게시판"), + RECRUIT("모집_게시판"); + + private final String name; + BoardCategory(String name) { + this.name = name; + } + + @JsonValue + public String getName(){ + return name; + } + + @JsonCreator + public static BoardCategory fromName(String name) { + for(BoardCategory category : BoardCategory.values()){ + if(category.name().equalsIgnoreCase(name) || category.getName().equals(name)){ + return category; + } + } + throw new ApiException(INVALID_INPUT); + } + +} diff --git a/src/main/java/com/example/gamemate/domain/board/enums/ListSize.java b/src/main/java/com/example/gamemate/domain/board/enums/ListSize.java new file mode 100644 index 0000000..000f1e2 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/enums/ListSize.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.board.enums; + +import lombok.Getter; + +@Getter +public enum ListSize { + BOARD_LIST_SIZE(15), + COMMENT_LIST_SIZE(25),; + + + private final int size; + ListSize(int size) { + this.size = size; + } + +} diff --git a/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepository.java b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepository.java new file mode 100644 index 0000000..b92479d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepository.java @@ -0,0 +1,12 @@ +package com.example.gamemate.domain.board.repository; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.board.enums.BoardCategory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface BoardQuerydslRepository { + Page searchBoardQuerydsl(BoardCategory category, String title, String content, Pageable pageable); +} diff --git a/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepositoryImpl.java b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepositoryImpl.java new file mode 100644 index 0000000..d1ae209 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/repository/BoardQuerydslRepositoryImpl.java @@ -0,0 +1,63 @@ +package com.example.gamemate.domain.board.repository; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.board.entity.QBoard; +import com.example.gamemate.domain.board.enums.BoardCategory; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.util.List; + +@RequiredArgsConstructor +public class BoardQuerydslRepositoryImpl implements BoardQuerydslRepository { + private final JPAQueryFactory jpaQueryFactory; + + /** + * 게시글 조회 + * @param category + * @param title + * @param content + * @return + */ + @Override + public Page searchBoardQuerydsl(BoardCategory category, String title, String content, Pageable pageable) { + + QBoard board = QBoard.board; + + BooleanBuilder builder = new BooleanBuilder(); + + if(category != null) { + builder.and(board.category.eq(category)); + } + + if(title != null) { + builder.and(board.title.like("%"+title+"%")); + } + + if(content != null) { + builder.and(board.content.like("%"+content+"%")); + } + + JPAQuery query = jpaQueryFactory.selectFrom(board) + .where(builder) + .orderBy(new OrderSpecifier<>(Order.DESC, board.createdAt)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + + List contentList = query.fetch(); + + JPAQuery countQuery = jpaQueryFactory.select(board.count()) + .from(board) + .where(builder); + + return PageableExecutionUtils.getPage(contentList, pageable, countQuery::fetchOne); + } +} diff --git a/src/main/java/com/example/gamemate/domain/board/repository/BoardRepository.java b/src/main/java/com/example/gamemate/domain/board/repository/BoardRepository.java new file mode 100644 index 0000000..a66b356 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/repository/BoardRepository.java @@ -0,0 +1,29 @@ +package com.example.gamemate.domain.board.repository; + +import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto; +import com.example.gamemate.domain.board.entity.Board; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BoardRepository extends JpaRepository, BoardQuerydslRepository{ + Page findByCategory(String category, Pageable pageable); + + List findTop5ByOrderByViewsDesc(); + + @Query("select b from Board b where b.id IN :boardIds order by b.views desc, b.createdAt desc") + List findByIdInOrderByCreatedAtDesc(@Param("boardIds") List boardIds); + + List findTop5ByOrderByCreatedAtDesc(); + + @Query("select b from Board b where b.id IN :boardIds order by b.views desc, b.createdAt desc") + List findByIdInOrderByViewsDescCreatedAtDesc(@Param("boardIds") List boardIds); + + List findByIdIn(List boardIds); +} diff --git a/src/main/java/com/example/gamemate/domain/board/service/BoardService.java b/src/main/java/com/example/gamemate/domain/board/service/BoardService.java new file mode 100644 index 0000000..12ae91b --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/service/BoardService.java @@ -0,0 +1,148 @@ +package com.example.gamemate.domain.board.service; + +import com.example.gamemate.domain.board.dto.BoardRequestDto; +import com.example.gamemate.domain.board.dto.BoardResponseDto; +import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto; +import com.example.gamemate.domain.board.dto.BoardFindOneResponseDto; +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.board.enums.BoardCategory; +import com.example.gamemate.domain.board.enums.ListSize; +import com.example.gamemate.domain.board.repository.BoardRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BoardService { + + private final BoardRepository boardRepository; + private final BoardViewService boardViewService; + + /** + * 게시글 생성 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param dto 게시글 생성 dto + * @return 게시글 생성 응답 ResponseDto + */ + @Transactional + public BoardResponseDto createBoard(User loginUser, BoardRequestDto dto) { + // 게시글 생성 + Board newBoard = new Board(dto.getCategory(),dto.getTitle(),dto.getContent(), loginUser); + Board createdBoard = boardRepository.save(newBoard); + return new BoardResponseDto( + createdBoard.getId(), + createdBoard.getCategory(), + createdBoard.getTitle(), + createdBoard.getContent(), + createdBoard.getUser().getNickname(), + createdBoard.getCreatedAt(), + createdBoard.getModifiedAt() + ); + } + + /** + * 게시판 리스트 조회 메서드입니다. + * + * @param page 페이지 번호 (기본값 : 0) + * @param category 게시글 카테고리 + * @param title 게시글 제목 + * @param content 게시글 내용 + * @return 게시글 리스트 ResponseDto List + */ + public List findAllBoards(int page, BoardCategory category, String title, String content) { + + Pageable pageable = PageRequest.of(page, ListSize.BOARD_LIST_SIZE.getSize(), Sort.by(Sort.Order.desc("createdAt"))); + + Page boardPage = boardRepository.searchBoardQuerydsl(category, title, content, pageable); + + return boardPage.stream() + .map(board -> new BoardFindAllResponseDto( + board.getId(), + board.getCategory(), + board.getTitle(), + board.getCreatedAt(), + boardViewService.getViewCount(board.getId()) + )) + .collect(Collectors.toList()); + } + + /** + * 게시글 단건 조회 메서드입니다. + * + * @param id 게시글 식별자 + * @return 게시글 조회 ResponseDto + */ + @Transactional + public BoardFindOneResponseDto findBoardById(Long id, User loginUser) { + // 조회수 증가(Redis 저장) + if(loginUser == null) { + boardViewService.increaseViewCount(id, null); + }else{ + boardViewService.increaseViewCount(id, loginUser.getId()); + } + + // 게시글 조회 + Board findBoard = boardRepository.findById(id) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND)); + + return new BoardFindOneResponseDto(findBoard); + } + + + /** + * 게시글 업데이트 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param id 게시글 식별자 + * @param dto 게시글 업데이트 요청 Dto + */ + @Transactional + public void updateBoard(User loginUser, Long id, BoardRequestDto dto) { + // 게시글 조회 + Board findBoard = boardRepository.findById(id) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND)); + + // 게시글 작성자와 로그인한 사용자 확인 + if(!findBoard.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + findBoard.updateBoard(dto.getCategory(),dto.getTitle(),dto.getContent()); + boardRepository.save(findBoard); + } + + /** + * 게시글 삭제 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param id 게시글 식별자 + */ + @Transactional + public void deleteBoard(User loginUser, Long id) { + //게시글 조회 + Board findBoard = boardRepository.findById(id) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND)); + + // 게시글 작성자와 로그인한 사용자 확인 + if(!findBoard.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + boardRepository.delete(findBoard); + } + + +} diff --git a/src/main/java/com/example/gamemate/domain/board/service/BoardViewService.java b/src/main/java/com/example/gamemate/domain/board/service/BoardViewService.java new file mode 100644 index 0000000..b0f4d1f --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/board/service/BoardViewService.java @@ -0,0 +1,210 @@ +package com.example.gamemate.domain.board.service; + +import com.example.gamemate.domain.board.dto.BoardFindAllResponseDto; +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.board.enums.BoardCategory; +import com.example.gamemate.domain.board.repository.BoardRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class BoardViewService { + + private final BoardRepository boardRepository; + private final String VIEW_COUNT_KEY = "board:view:"; + private final String VIEW_RANKING_KEY = "board:ranking:"; + private final StringRedisTemplate redisTemplate; + private final HttpServletRequest request; + + public BoardViewService( + BoardRepository boardRepository, + @Qualifier("viewCountRedisTemplate")StringRedisTemplate redisTemplate, + HttpServletRequest request) { + this.boardRepository = boardRepository; + this.redisTemplate = redisTemplate; + this.request = request; + } + + /** + * 조회수 높은 게시글 조회 하는 메서드입니다. + * + * @param boardCategory 카테고리 종류 + * @return 게시글 조회 List + */ + public List findTopBoards(BoardCategory boardCategory) { + List top5Boards = getTop5Boards(); + + List result = new ArrayList<>(); + + for(Board board : top5Boards) { + int redisViewCount = getViewCount(board.getId()); + result.add(new BoardFindAllResponseDto( + board.getId(), + board.getCategory(), + board.getTitle(), + board.getCreatedAt(), + redisViewCount + )); + } + + return result; + } + + /** + * 조회수 증가시키는 메서드입니다. + * + * @param boardId 게시글 식별자 + */ + @Transactional + public void increaseViewCount(Long boardId, Long userId) { + String uniqueKey; + + if (userId != null) { + // 회원 : userId 기반으로 조회 제한 + uniqueKey = VIEW_COUNT_KEY + boardId + ":" + userId; + } else { + // 비회원 + String ipAddress = getClientIp(); + //String hashedIp = hashIpAddress(ipAddress); + uniqueKey = VIEW_COUNT_KEY + boardId + ":" + ipAddress; + } + + if (Boolean.FALSE.equals(redisTemplate.hasKey(uniqueKey))) { + redisTemplate.opsForValue().set(uniqueKey, "1", Duration.ofHours(1)); + redisTemplate.opsForValue().increment(VIEW_COUNT_KEY + boardId); + redisTemplate.opsForZSet().incrementScore(VIEW_RANKING_KEY, String.valueOf(boardId),1); + } + } + + /** + * 조회수 가져오는 메서드 입니다. + * + * @param boardId 게시글 식별자 + * @return 조회수 + */ + public int getViewCount(Long boardId){ + + String key = VIEW_COUNT_KEY + boardId; + String count = redisTemplate.opsForValue().get(key); + + if(count != null){ + return Integer.parseInt(count); + } + + // Redis에 값이 없으면 DB에서 조회 후 Redis 에 반영 + Board board = boardRepository.findById(boardId) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND)); + + int dbViewCount = board.getViews(); + + // Redis 에 저장(초기화) + redisTemplate.opsForValue().set(key, String.valueOf(dbViewCount)); + + return dbViewCount; + } + + /** + * 클라이언트 IP 가져오는 메서드입니다.(프록시) + * + * @return ip 주소 + */ + private String getClientIp(){ + + String ip = request.getHeader("x-forwarded-for"); + if( ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + + if( ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + + if( ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } + + + /** + * 매일 00시 정각 시간마다 동기화 + */ + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void syncRedisToDb(){ + log.info("Redis 조회수 데이터 DB로 동기화"); + + // 조회수 기반으로 DB에 업데이트 + Set keys = redisTemplate.keys(VIEW_COUNT_KEY + "*") + .stream() + .filter(key -> key.split(":").length == 3) // board:view:{boardId} 형식만 남김 + .collect(Collectors.toSet()); + if(!keys.isEmpty()){ + List updatedBoards = new ArrayList<>(); + for(String key : keys){ + Long boardId = Long.parseLong(key.split(":")[2]); + int viewCount = getViewCount(boardId); + if(viewCount > 0){ + Board findBoard = boardRepository.findById(boardId) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND)); + int boardViewCount = findBoard.getViews(); + findBoard.updateViewCount( viewCount); + updatedBoards.add(findBoard); + + // redis 값을 유지(db 값 반영 후 덮어쓰기) + redisTemplate.opsForValue().set(VIEW_COUNT_KEY + boardId, String.valueOf(viewCount)); + } + } + // 업데이트 + boardRepository.saveAll(updatedBoards); + log.info("업데이트 완료"); + } + + } + + /** + * 조회수 높은 5개의 게시글 조회 + * + * @return 조회수 높은 게시글 리스트 + */ + public List getTop5Boards(){ + Set topBoardIds = redisTemplate.opsForZSet().reverseRange(VIEW_RANKING_KEY, 0, 4); + + if(topBoardIds == null || topBoardIds.isEmpty()){ + return boardRepository.findTop5ByOrderByCreatedAtDesc(); + } + + List boardIds = topBoardIds.stream().map(Long::parseLong).toList(); + + // DB 에서 해당 게시글 조회(조회수 순으로 정렬) + List boards = boardRepository.findByIdIn(boardIds); + + // 조회수는 redis 값으로 최신화 + boards.forEach(board -> { + int redisViewCount = getViewCount(board.getId()); + board.updateViewCount(redisViewCount); + }); + + boards.sort(Comparator.comparing(Board::getViews).reversed() + .thenComparing(Board::getCreatedAt, Comparator.reverseOrder())); + + return boards; + } +} diff --git a/src/main/java/com/example/gamemate/domain/boardImage/controller/BoardImageController.java b/src/main/java/com/example/gamemate/domain/boardImage/controller/BoardImageController.java new file mode 100644 index 0000000..ab7d84a --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/boardImage/controller/BoardImageController.java @@ -0,0 +1,75 @@ +package com.example.gamemate.domain.boardImage.controller; + +import com.example.gamemate.domain.boardImage.service.BoardImageService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/boards/{boardId}/files") +public class BoardImageController { + + private final BoardImageService boardImageService; + + /** + * 게시글 첨부파일 추가 API + * @param boardId 보드 식별자 + * @param image 게시글에 첨부할 이미지 + * @param customUserDetails 인증된 사용자 정보 + * @return String ResponseEntity + * @throws IOException 오류 발생 + */ + @PostMapping + public ResponseEntity createBoardImage( + @PathVariable Long boardId, + @RequestParam("image") MultipartFile image, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) throws IOException { + boardImageService.createBoardImage(customUserDetails.getUser(), boardId, image); + return new ResponseEntity<>("업로드", HttpStatus.CREATED); + } + + /** + * 게시글 첨부파일 수정 API + * + * @param id 이미지 식별자 + * @param image 수정할 게시글 이미지 + * @param customUserDetails 인증된 사용자 정보 + * @return Void ResponseEntity + */ + @PutMapping("/{id}") + public ResponseEntity updateBoardImage( + @PathVariable Long id, + @RequestParam("image") MultipartFile image, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) throws IOException { + boardImageService.updateBoardImage(customUserDetails.getUser(), id, image); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + + /** + * 이미지 삭제 API + * + * @param id 이미지 식별자 + * @param customUserDetails 인증된 사용자 정보 + * @return Void ResponseEntity + * @throws IOException 오류 발생 + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteBoardImage( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) throws IOException { + boardImageService.deleteImage(customUserDetails.getUser(), id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + +} diff --git a/src/main/java/com/example/gamemate/domain/boardImage/entity/BoardImage.java b/src/main/java/com/example/gamemate/domain/boardImage/entity/BoardImage.java new file mode 100644 index 0000000..6e88cbb --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/boardImage/entity/BoardImage.java @@ -0,0 +1,48 @@ +package com.example.gamemate.domain.boardImage.entity; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.global.common.BaseCreatedEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "board_image") +public class BoardImage extends BaseCreatedEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "board_id") + private Board board; + + @Column(nullable = false) + private String fileName; + + @Column(nullable = false) + private String fileType; + + @Column(nullable = false) + private String filePath; + + @Column(nullable = false) + private Long fileSize; + + public BoardImage(String fileName, String fileType, String filePath, Long fileSize, Board board) { + this.fileName = fileName; + this.fileType = fileType; + this.filePath = filePath; + this.fileSize = fileSize; + this.board = board; + } + + public void updateBoardImage(String fileName, String fileType, String filePath, Long fileSize) { + this.fileName = fileName; + this.fileType = fileType; + this.filePath = filePath; + this.fileSize = fileSize; + } +} diff --git a/src/main/java/com/example/gamemate/domain/boardImage/repository/BoardImageRepository.java b/src/main/java/com/example/gamemate/domain/boardImage/repository/BoardImageRepository.java new file mode 100644 index 0000000..5b7db3a --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/boardImage/repository/BoardImageRepository.java @@ -0,0 +1,7 @@ +package com.example.gamemate.domain.boardImage.repository; + +import com.example.gamemate.domain.boardImage.entity.BoardImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BoardImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/gamemate/domain/boardImage/service/BoardImageService.java b/src/main/java/com/example/gamemate/domain/boardImage/service/BoardImageService.java new file mode 100644 index 0000000..5fa8aa6 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/boardImage/service/BoardImageService.java @@ -0,0 +1,117 @@ +package com.example.gamemate.domain.boardImage.service; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.boardImage.entity.BoardImage; +import com.example.gamemate.domain.boardImage.repository.BoardImageRepository; +import com.example.gamemate.domain.board.repository.BoardRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import com.example.gamemate.global.s3.S3Service; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BoardImageService { + + private final S3Service s3Service; + private final BoardImageRepository boardImageRepository; + private final BoardRepository boardRepository; + + /** + * 이미지 업로드 메서드 + * + * @param loginUser 로그인한 유저 + * @param boardId 게시글 식별자 + * @param image 게시글에 업로드할 이미지 + * @throws IOException 오류 발생 + */ + @Transactional + public void createBoardImage(User loginUser, Long boardId, MultipartFile image) throws IOException { + // 게시글 조회 + Board findBoard = boardRepository.findById(boardId) + .orElseThrow(()-> new ApiException(ErrorCode.BOARD_NOT_FOUND)); + + // 게시글 작성자와 로그인한 유저 확인 + if(!findBoard.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + //업로드 된 이미지 파일 주소 + String publicUrl = s3Service.uploadFile(image); + //BoardImage 테이블에 담을 변수 생성 + BoardImage boardImage = new BoardImage(image.getOriginalFilename(), image.getContentType(), publicUrl, image.getSize(),findBoard); + + //BoardImage 테이블에 저장 + boardImageRepository.save(boardImage); + } + + /** + * 이미지 업데이트 메서드 + * + * @param loginUser 로그인한 유저 + * @param id 이미지 식별자 + * @param image 게시글에 업데이트할 이미지 + * @throws IOException 오류 발생 + */ + @Transactional + public void updateBoardImage(User loginUser, Long id, MultipartFile image) throws IOException { + // 이미지 조회 + BoardImage findBoardImage = boardImageRepository.findById(id) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_IMAGE_NOT_FOUND)); + + // 이미지 업로드 유저와 로그인 유저 확인 + if(!findBoardImage.getBoard().getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + //업로드 된 이미지 파일 주소 + String publicUrl = s3Service.uploadFile(image); + //BoardImage 테이블에 담을 변수 생성 + findBoardImage.updateBoardImage(image.getOriginalFilename(), image.getContentType(), publicUrl, image.getSize()); + + //BoardImage 테이블에 저장 + boardImageRepository.save(findBoardImage); + + try{ + // S3 에서 이미지 삭제 + s3Service.deleteFile(findBoardImage.getFilePath()); + } catch(Exception e){ + log.error("파일 업로드 에러 발생 : {}",e.getMessage()); + } + + } + + + /** + * 이미지 삭제 메서드 + * @param loginUser 로그인한 유저 + * @param id 이미지 식별자 + */ + @Transactional + public void deleteImage(User loginUser, Long id) { + // 이미지 조회 + BoardImage findBoardImage = boardImageRepository.findById(id) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_IMAGE_NOT_FOUND)); + + // 이미지 업로드 유저와 로그인 유저 확인 + if(!findBoardImage.getBoard().getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + // S3 에서 이미지 삭제 + s3Service.deleteFile(findBoardImage.getFilePath()); + + // 이미지 삭제 + boardImageRepository.delete(findBoardImage); + } + + +} diff --git a/src/main/java/com/example/gamemate/domain/comment/controller/CommentController.java b/src/main/java/com/example/gamemate/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..d4fa8f2 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/comment/controller/CommentController.java @@ -0,0 +1,88 @@ +package com.example.gamemate.domain.comment.controller; + +import com.example.gamemate.domain.comment.dto.CommentFindResponseDto; +import com.example.gamemate.domain.comment.dto.CommentRequestDto; +import com.example.gamemate.domain.comment.dto.CommentResponseDto; +import com.example.gamemate.domain.comment.service.CommentService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/boards/{boardId}/comments") +public class CommentController { + + private final CommentService commentService; + + /** + * 댓글 생성 API 입니다. + * + * @param boardId 게시글 식별자 + * @param requestDto 댓글 요청 Dto + * @return 생성된 댓글 정보를 포함한 ResponseEntity + */ + @PostMapping + public ResponseEntity createComment( + @PathVariable Long boardId, + @RequestBody CommentRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + CommentResponseDto dto = commentService.createComment(customUserDetails.getUser(),boardId, requestDto); + return new ResponseEntity<>(dto, HttpStatus.CREATED); + } + + /** + * 댓글/대댓글 조회 입니다. + * + * @param boardId 댓글 식별자 + * @param page 페이지 번호(기본값 : 0) + * @return 댓글 리스트 ResponseEntity + */ + @GetMapping + public ResponseEntity> getComments( + @PathVariable Long boardId, + @RequestParam(defaultValue = "0") int page + ){ + List dtos = commentService.getComments(boardId, page); + return new ResponseEntity<>(dtos, HttpStatus.OK); + } + + /** + * 댓글 수정 API 입니다. + * + * @param id 댓글 식별자 + * @param requestDto 업데이트할 댓글 Dto + * @return Void + */ + @PatchMapping("/{id}") + public ResponseEntity updateComment( + @PathVariable Long id, + @RequestBody CommentRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + commentService.updateComment(customUserDetails.getUser(), id, requestDto); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 댓글 삭제 API 입니다. + * + * @param id 댓글 식별자 + * @param customUserDetails 인증된 사용자 정보 + * @return Void + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteComment( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + commentService.deleteComment(customUserDetails.getUser(), id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/gamemate/domain/comment/dto/CommentFindResponseDto.java b/src/main/java/com/example/gamemate/domain/comment/dto/CommentFindResponseDto.java new file mode 100644 index 0000000..a45c897 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/comment/dto/CommentFindResponseDto.java @@ -0,0 +1,27 @@ +package com.example.gamemate.domain.comment.dto; + +import com.example.gamemate.domain.reply.dto.ReplyFindResponseDto; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Getter +public class CommentFindResponseDto { + private final Long commentId; + private final String content; + private final String nickname; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + private final List replies; + + public CommentFindResponseDto(Long commentId, String content, String nickname, LocalDateTime createdAt, LocalDateTime updatedAt, List replies) { + this.commentId = commentId; + this.content = content; + this.nickname = nickname; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.replies = replies; + } +} diff --git a/src/main/java/com/example/gamemate/domain/comment/dto/CommentRequestDto.java b/src/main/java/com/example/gamemate/domain/comment/dto/CommentRequestDto.java new file mode 100644 index 0000000..885e563 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/comment/dto/CommentRequestDto.java @@ -0,0 +1,14 @@ +package com.example.gamemate.domain.comment.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CommentRequestDto { + private String content; + + public CommentRequestDto(String content) { + this.content = content; + } +} diff --git a/src/main/java/com/example/gamemate/domain/comment/dto/CommentResponseDto.java b/src/main/java/com/example/gamemate/domain/comment/dto/CommentResponseDto.java new file mode 100644 index 0000000..a30a11b --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/comment/dto/CommentResponseDto.java @@ -0,0 +1,22 @@ +package com.example.gamemate.domain.comment.dto; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CommentResponseDto { + private final Long id; + private final String content; + private final String nickname; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public CommentResponseDto(Long id, String content, String nickname, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.content = content; + this.nickname = nickname; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/example/gamemate/domain/comment/entity/Comment.java b/src/main/java/com/example/gamemate/domain/comment/entity/Comment.java new file mode 100644 index 0000000..2c1bd9c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/comment/entity/Comment.java @@ -0,0 +1,42 @@ +package com.example.gamemate.domain.comment.entity; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.reply.entity.Reply; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "comment") +public class Comment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String content; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "board_id") + private Board board; + + public Comment(String content, Board board, User user) { + this.content = content; + this.board = board; + this.user = user; + } + + public void updateComment(String content) { + this.content = content; + } +} diff --git a/src/main/java/com/example/gamemate/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/gamemate/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..2b88a0e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/comment/repository/CommentRepository.java @@ -0,0 +1,14 @@ +package com.example.gamemate.domain.comment.repository; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.comment.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + Page findByBoard(Board findBoard, Pageable pageable); +} diff --git a/src/main/java/com/example/gamemate/domain/comment/service/CommentService.java b/src/main/java/com/example/gamemate/domain/comment/service/CommentService.java new file mode 100644 index 0000000..fe3e964 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/comment/service/CommentService.java @@ -0,0 +1,172 @@ +package com.example.gamemate.domain.comment.service; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.board.enums.ListSize; +import com.example.gamemate.domain.board.repository.BoardRepository; +import com.example.gamemate.domain.comment.dto.CommentFindResponseDto; +import com.example.gamemate.domain.comment.dto.CommentRequestDto; +import com.example.gamemate.domain.comment.dto.CommentResponseDto; +import com.example.gamemate.domain.comment.entity.Comment; +import com.example.gamemate.domain.comment.repository.CommentRepository; +import com.example.gamemate.domain.reply.dto.ReplyFindResponseDto; +import com.example.gamemate.domain.reply.entity.Reply; +import com.example.gamemate.domain.reply.repository.ReplyRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.eventListener.event.CommentCreatedEvent; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final ReplyRepository replyRepository; + private final BoardRepository boardRepository; + private final ApplicationEventPublisher publisher; + + /** + * 댓글 생성 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param boardId 게시글 식별자 + * @param requestDto 댓글 생성할 requestDto + * @return CommentResponseDto + */ + @Transactional + public CommentResponseDto createComment(User loginUser, Long boardId, CommentRequestDto requestDto) { + // 게시글 조회 + Board findBoard = boardRepository.findById(boardId) + .orElseThrow(() -> new ApiException(ErrorCode.BOARD_NOT_FOUND)); + + Comment comment = new Comment(requestDto.getContent(), findBoard, loginUser); + Comment createComment = commentRepository.save(comment); + + publisher.publishEvent(new CommentCreatedEvent(this, createComment)); + + return new CommentResponseDto( + createComment.getId(), + createComment.getContent(), + createComment.getUser().getNickname(), + createComment.getCreatedAt(), + createComment.getModifiedAt() + ); + } + + /** + * 댓글 업데이트 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param id 댓글 식별자 + * @param requestDto 업데이트할 댓글 dto + */ + @Transactional + public void updateComment(User loginUser, Long id, CommentRequestDto requestDto) { + // 댓글 조회 + Comment findComment = commentRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + // 댓글 작성자와 로그인한 유저 확인 + if(!findComment.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + findComment.updateComment(requestDto.getContent()); + commentRepository.save(findComment); + } + + /** + * 댓글 삭제 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param id 댓글 식별자 + */ + @Transactional + public void deleteComment(User loginUser, Long id) { + // 댓글 조회 + Comment findComment = commentRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + // 댓글 작성자와 로그인한 유저 확인 + if(!findComment.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + commentRepository.delete(findComment); + } + + /** + * 댓글 조회 메서드입니다. + * + * @param boardId 게시글 식별자 + * @param page 페이지 번호(기본값 : 0) + * @return Comment 조회 Do + */ + public List getComments(Long boardId, int page) { + // page는 댓글 페이지네이션을 위해 필요 + Pageable pageable = PageRequest.of(page, ListSize.COMMENT_LIST_SIZE.getSize(), Sort.by(Sort.Order.asc("createdAt"))); + + //게시글 조회 + Board findBoard = boardRepository.findById(boardId) + .orElseThrow(()->new ApiException(ErrorCode.BOARD_NOT_FOUND)); + + // 댓글 조회 + Page comments = commentRepository.findByBoard(findBoard,pageable); + + return comments.stream() + .map(this::convertCommentDto) + .collect(Collectors.toList()); + } + + /** + * 댓글 Dto 변환입니다. + * + * @param comment comment + * @return 댓글 조회 Dto + */ + private CommentFindResponseDto convertCommentDto(Comment comment) { + List replyDtos = Optional.ofNullable(replyRepository.findByComment(comment)) + .orElse(Collections.emptyList()) + .stream() + .map(this::convertReplyDto) + .collect(Collectors.toList()); + return new CommentFindResponseDto( + comment.getId(), + comment.getContent(), + comment.getUser().getNickname(), + comment.getCreatedAt(), + comment.getModifiedAt(), + replyDtos + ); + } + + /** + * 대댓글 Dto 변환입니다. + * + * @param reply 대댓글 + * @return 대댓글 조회 Dto + */ + private ReplyFindResponseDto convertReplyDto(Reply reply) { + String findUserName = reply.getParentReply() == null ? null : reply.getParentReply().getUser().getNickname(); + return new ReplyFindResponseDto( + reply.getId(), + findUserName, + reply.getContent(), + reply.getCreatedAt(), + reply.getModifiedAt() + ); + } +} diff --git a/src/main/java/com/example/gamemate/domain/coupon/controller/CouponController.java b/src/main/java/com/example/gamemate/domain/coupon/controller/CouponController.java new file mode 100644 index 0000000..6fcd1f6 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/controller/CouponController.java @@ -0,0 +1,58 @@ +package com.example.gamemate.domain.coupon.controller; + +import com.example.gamemate.domain.coupon.dto.CouponCreateRequestDto; +import com.example.gamemate.domain.coupon.dto.CouponCreateResponseDto; +import com.example.gamemate.domain.coupon.dto.CouponIssueResponseDto; +import com.example.gamemate.domain.coupon.service.CouponService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/coupons") +@RequiredArgsConstructor +public class CouponController { + private final CouponService couponService; + + @PostMapping("/create") + public ResponseEntity createCoupon( + @Valid @RequestBody CouponCreateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + CouponCreateResponseDto responseDto = couponService.createCoupon(requestDto, customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + @PostMapping("/{couponId}/issue") + public ResponseEntity issueCoupon( + @PathVariable Long couponId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + CouponIssueResponseDto responseDto = couponService.issueCoupon(couponId, customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + @GetMapping("/my") + public ResponseEntity> findMyCoupons( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + List responseDtos = couponService.findMyCoupons(customUserDetails.getUser()); + return new ResponseEntity<>(responseDtos, HttpStatus.OK); + } + + @PostMapping("/user-coupons/{userCouponId}/use") + public ResponseEntity useCoupon( + @PathVariable Long userCouponId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + couponService.useCoupon(userCouponId, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} + diff --git a/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateRequestDto.java new file mode 100644 index 0000000..8df4e42 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateRequestDto.java @@ -0,0 +1,35 @@ +package com.example.gamemate.domain.coupon.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@RequiredArgsConstructor +public class CouponCreateRequestDto { + + @NotBlank(message = "쿠폰 코드를 입력해주세요.") + private final String code; + + @NotBlank(message = "쿠폰 이름을 입력해주세요.") + private final String name; + + @Positive(message = "할인 금액을 양수로 입력해주세요.") + @NotNull(message = "할인 금액을 입력해주세요.") + private final Integer discountAmount; + + @NotNull(message = "시작 시간을 입력해주세요.") + private final LocalDateTime startAt; + + @NotNull(message = "종료 시간을 입력해주세요.") + private final LocalDateTime expiredAt; + + @NotNull(message = "쿠폰 수량을 입력해주세요.") + private final Integer quantity; + +} + diff --git a/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateResponseDto.java b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateResponseDto.java new file mode 100644 index 0000000..d02dc4c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponCreateResponseDto.java @@ -0,0 +1,27 @@ +package com.example.gamemate.domain.coupon.dto; + +import com.example.gamemate.domain.coupon.entity.Coupon; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CouponCreateResponseDto { + private final Long id; + private final String code; + private final String name; + private final Integer discountAmount; + private final LocalDateTime startAt; + private final LocalDateTime expiredAt; + private final Integer quantity; + + public CouponCreateResponseDto(Coupon coupon) { + this.id = coupon.getId(); + this.code = coupon.getCode(); + this.name = coupon.getName(); + this.discountAmount = coupon.getDiscountAmount(); + this.startAt = coupon.getStartAt(); + this.expiredAt = coupon.getExpiredAt(); + this.quantity = coupon.getTotalQuantity(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/coupon/dto/CouponIssueResponseDto.java b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponIssueResponseDto.java new file mode 100644 index 0000000..2ea4161 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/dto/CouponIssueResponseDto.java @@ -0,0 +1,33 @@ +package com.example.gamemate.domain.coupon.dto; + +import com.example.gamemate.domain.coupon.entity.Coupon; +import com.example.gamemate.domain.coupon.entity.UserCoupon; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CouponIssueResponseDto { + + private final Long id; + private final String code; + private final String name; + private final Integer discountAmount; + private final LocalDateTime issuedAt; + private final LocalDateTime startAt; + private final LocalDateTime expiredAt; + private final Boolean isUsed; + + public CouponIssueResponseDto(UserCoupon userCoupon) { + Coupon coupon = userCoupon.getCoupon(); + this.id = userCoupon.getId(); + this.code = coupon.getCode(); + this.name = coupon.getName(); + this.discountAmount = coupon.getDiscountAmount(); + this.startAt = coupon.getStartAt(); + this.expiredAt = coupon.getExpiredAt(); + this.issuedAt = userCoupon.getIssuedAt(); + this.isUsed = userCoupon.getIsUsed(); + } +} + diff --git a/src/main/java/com/example/gamemate/domain/coupon/entity/Coupon.java b/src/main/java/com/example/gamemate/domain/coupon/entity/Coupon.java new file mode 100644 index 0000000..25e7645 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/entity/Coupon.java @@ -0,0 +1,67 @@ +package com.example.gamemate.domain.coupon.entity; + +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "coupon") +public class Coupon extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String code; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Integer discountAmount; + + @Column(nullable = false) + private Integer totalQuantity; + + @Column(nullable = false) + private Integer issuedQuantity = 0; + + @Column(nullable = false) + private LocalDateTime startAt; + + @Column(nullable = false) + private LocalDateTime expiredAt; + + @OneToMany(mappedBy = "coupon") + private List userCoupons = new ArrayList<>(); + + public Coupon(String code, String name, Integer discountAmount, Integer totalQuantity, LocalDateTime startAt, LocalDateTime expiredAt) { + this.code = code; + this.name = name; + this.discountAmount = discountAmount; + this.totalQuantity = totalQuantity; + this.startAt = startAt; + this.expiredAt = expiredAt; + } + + public boolean isIssuable() { + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(startAt) && now.isBefore(expiredAt); + } + + public boolean isExhausted() { + return issuedQuantity >= totalQuantity; + } + + public void incrementIssuedQuantity() { + this.issuedQuantity++; + } +} + diff --git a/src/main/java/com/example/gamemate/domain/coupon/entity/UserCoupon.java b/src/main/java/com/example/gamemate/domain/coupon/entity/UserCoupon.java new file mode 100644 index 0000000..e89ffe0 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/entity/UserCoupon.java @@ -0,0 +1,50 @@ +package com.example.gamemate.domain.coupon.entity; + +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "user_coupon") +public class UserCoupon extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Boolean isUsed = false; + + @Column(nullable = false) + private LocalDateTime issuedAt; + + private LocalDateTime usedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "coupon_id", nullable = false) + private Coupon coupon; + + public UserCoupon(User user, Coupon coupon) { + this.user = user; + this.coupon = coupon; + this.issuedAt = LocalDateTime.now(); + } + + public void updateIsUsed(Boolean isUsed) { + this.isUsed = isUsed; + } + + public void updateUsedAt() { + this.usedAt = LocalDateTime.now(); + } +} + diff --git a/src/main/java/com/example/gamemate/domain/coupon/repository/CouponRepository.java b/src/main/java/com/example/gamemate/domain/coupon/repository/CouponRepository.java new file mode 100644 index 0000000..0fdc3e3 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/repository/CouponRepository.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.coupon.repository; + +import com.example.gamemate.domain.coupon.entity.Coupon; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface CouponRepository extends JpaRepository { + boolean existsByCode(String code); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from Coupon c where c.id = :id") + Optional findByIdWithPessimisticLock(@Param("id") Long id); +} diff --git a/src/main/java/com/example/gamemate/domain/coupon/repository/UserCouponRepository.java b/src/main/java/com/example/gamemate/domain/coupon/repository/UserCouponRepository.java new file mode 100644 index 0000000..eac2441 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/repository/UserCouponRepository.java @@ -0,0 +1,12 @@ +package com.example.gamemate.domain.coupon.repository; + +import com.example.gamemate.domain.coupon.entity.UserCoupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UserCouponRepository extends JpaRepository { + boolean existsByUserIdAndCouponId(Long userId, Long couponId); + List findByUserId(Long userId); + long countByCouponId(Long couponId); +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/domain/coupon/service/CouponService.java b/src/main/java/com/example/gamemate/domain/coupon/service/CouponService.java new file mode 100644 index 0000000..cd4d5ab --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/coupon/service/CouponService.java @@ -0,0 +1,118 @@ +package com.example.gamemate.domain.coupon.service; + +import com.example.gamemate.domain.coupon.dto.CouponCreateRequestDto; +import com.example.gamemate.domain.coupon.dto.CouponCreateResponseDto; +import com.example.gamemate.domain.coupon.dto.CouponIssueResponseDto; +import com.example.gamemate.domain.coupon.entity.Coupon; +import com.example.gamemate.domain.coupon.entity.UserCoupon; +import com.example.gamemate.domain.coupon.repository.CouponRepository; +import com.example.gamemate.domain.coupon.repository.UserCouponRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.Role; +import com.example.gamemate.global.common.aop.DistributedLock; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class CouponService { + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + + public CouponCreateResponseDto createCoupon(CouponCreateRequestDto requestDto, User loginUser) { + // 관리자 권한 체크 + if (!loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + // 쿠폰 코드 중복 확인 + if (couponRepository.existsByCode(requestDto.getCode())) { + throw new ApiException(ErrorCode.COUPON_CODE_DUPLICATED); + } + + // 쿠폰 사용 기간 유효성 검증 + validateCouponDates(requestDto.getStartAt(), requestDto.getExpiredAt()); + + // 쿠폰 생성 + Coupon coupon = new Coupon(requestDto.getCode(), requestDto.getName(), requestDto.getDiscountAmount(), requestDto.getQuantity(), requestDto.getStartAt(), requestDto.getExpiredAt()); + Coupon savedCoupon = couponRepository.save(coupon); + + return new CouponCreateResponseDto(savedCoupon); + } + + @DistributedLock(key = "'LOCK:coupon:' + #couponId") + public CouponIssueResponseDto issueCoupon(Long couponId, User loginUser) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new ApiException(ErrorCode.COUPON_NOT_FOUND)); + + // 발급 가능 체크 + if (!coupon.isIssuable()) { + throw new ApiException(ErrorCode.COUPON_NOT_ISSUABLE); + } + + // 중복 발급 체크 + if (userCouponRepository.existsByUserIdAndCouponId(loginUser.getId(), couponId)) { + throw new ApiException(ErrorCode.COUPON_ALREADY_ISSUED); + } + + // 수량 체크 + if (coupon.isExhausted()) { + throw new ApiException(ErrorCode.COUPON_EXHAUSTED); + } + + // 쿠폰 발급 + coupon.incrementIssuedQuantity(); + UserCoupon userCoupon = new UserCoupon(loginUser, coupon); + UserCoupon savedUserCoupon = userCouponRepository.save(userCoupon); + + return new CouponIssueResponseDto(savedUserCoupon); + } + + @Transactional(readOnly = true) + public List findMyCoupons(User loginUser) { + List userCoupons = userCouponRepository.findByUserId(loginUser.getId()); + return userCoupons.stream() + .map(CouponIssueResponseDto::new) + .collect(Collectors.toList()); + } + + public void useCoupon(Long userCouponId, User loginUser) { + UserCoupon userCoupon = userCouponRepository.findById(userCouponId) + .orElseThrow(()-> new ApiException(ErrorCode.COUPON_NOT_FOUND)); + + // 본인 쿠폰인지 확인 + if (!userCoupon.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + // 이미 사용된 쿠폰인지 확인 + if (userCoupon.getIsUsed()) { + throw new ApiException(ErrorCode.COUPON_ALREADY_USED); + } + + // 쿠폰 유효기간 확인 + if (LocalDateTime.now().isAfter(userCoupon.getCoupon().getExpiredAt())) { + throw new ApiException(ErrorCode.COUPON_EXPIRED); + } + + // 쿠폰 사용 처리 + userCoupon.updateIsUsed(true); + userCoupon.updateUsedAt(); + } + + private void validateCouponDates(LocalDateTime startAt, LocalDateTime expiredAt) { + if (startAt.isAfter(expiredAt)) { + throw new ApiException(ErrorCode.INVALID_COUPON_DATE); + } + } +} diff --git a/src/main/java/com/example/gamemate/domain/follow/controller/FollowController.java b/src/main/java/com/example/gamemate/domain/follow/controller/FollowController.java new file mode 100644 index 0000000..c1cd1f3 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/controller/FollowController.java @@ -0,0 +1,104 @@ +package com.example.gamemate.domain.follow.controller; + +import com.example.gamemate.domain.follow.service.FollowService; +import com.example.gamemate.domain.follow.dto.*; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 팔로우 기능을 처리하는 컨트롤러 클래스입니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/follows") +public class FollowController { + + private final FollowService followService; + + /** + * 사용자간의 팔로우를 생성을 처리합니다. + * + * @param dto FollowCreateRequestDto 팔로우할 상대방의 email + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 팔로우 처리 결과를 담은 ResponseEntity + */ + @PostMapping + public ResponseEntity createFollow( + @RequestBody FollowCreateRequestDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + FollowResponseDto followResponseDto = followService.createFollow(dto, customUserDetails.getUser()); + return new ResponseEntity<>(followResponseDto, HttpStatus.CREATED); + } + + /** + * 사용자간의 팔로우 취소를 처리합니다. + * + * @param id 취소할 팔로우 ID + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 204 NO_CONTENT 성공했지만 반환값이 없음 + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteFollow( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + followService.deleteFollow(id, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 팔로우 상태를 확인합니다. (loginUser 가 followee 를 팔로우 했는지 확인) + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @param email 팔로우 상태를 확인할 상대방 이메일 + * @return 로그인한 사용자가 상대방을 팔로우 했을시 true, 아닐시 false + */ + @GetMapping("/status") + public ResponseEntity findFollow( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam String email + ) { + + FollowBooleanResponseDto followBooleanResponseDto = followService.findFollow(customUserDetails.getUser(), email); + return new ResponseEntity<>(followBooleanResponseDto, HttpStatus.OK); + } + + /** + * 특정 유저의 팔로워 목록를 조회합니다. + * + * @param email 팔로워 목록을 보고 싶은 유저 email + * @return 특정 유저의 팔로워 목록을 담은 ResponseEntity + */ + @GetMapping("/followers") + public ResponseEntity> findFollowers( + @RequestParam String email + ) { + + List followFindResponseDtoList = followService.findFollowers(email); + return new ResponseEntity<>(followFindResponseDtoList, HttpStatus.OK); + } + + /** + * 특정 유저의 팔로잉 목록을 조회합니다. + * + * @param email 팔로잉 목록을 보고 싶은 유저 email + * @return 특정 유저의 팔로잉 목록을 담은 ResponseEntity + */ + @GetMapping("/following") + public ResponseEntity> findFollowing( + @RequestParam String email + ) { + + List followFindResponseDtoList = followService.findFollowing(email); + return new ResponseEntity<>(followFindResponseDtoList, HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowBooleanResponseDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowBooleanResponseDto.java new file mode 100644 index 0000000..b351d7d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowBooleanResponseDto.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.follow.dto; + +import lombok.Getter; + +@Getter +public class FollowBooleanResponseDto { + private boolean isFollowing; + private Long followerId; + private Long followeeId; + + public FollowBooleanResponseDto(boolean isFollowing, Long followerId, Long followeeId) { + this.isFollowing = isFollowing; + this.followerId = followerId; + this.followeeId = followeeId; + } +} diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowCreateRequestDto.java new file mode 100644 index 0000000..677696d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowCreateRequestDto.java @@ -0,0 +1,12 @@ +package com.example.gamemate.domain.follow.dto; + +import lombok.Getter; + +@Getter +public class FollowCreateRequestDto { + private String email; + + public FollowCreateRequestDto(String email) { + this.email = email; + } +} diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowFindResponseDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowFindResponseDto.java new file mode 100644 index 0000000..6013f2e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowFindResponseDto.java @@ -0,0 +1,19 @@ +package com.example.gamemate.domain.follow.dto; + +import com.example.gamemate.domain.user.entity.User; +import lombok.Getter; + +@Getter +public class FollowFindResponseDto { + private Long id; + private String nickname; + + public FollowFindResponseDto(Long id, String nickname) { + this.id = id; + this.nickname = nickname; + } + + public static FollowFindResponseDto toDto(User user) { + return new FollowFindResponseDto(user.getId(), user.getNickname()); + } +} diff --git a/src/main/java/com/example/gamemate/domain/follow/dto/FollowResponseDto.java b/src/main/java/com/example/gamemate/domain/follow/dto/FollowResponseDto.java new file mode 100644 index 0000000..78b0d4f --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/dto/FollowResponseDto.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.follow.dto; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class FollowResponseDto { + private Long id; + private Long followerId; + private Long followeeId; + private LocalDateTime createdAt; + + public FollowResponseDto(Long id, Long followerId, Long followeeId, LocalDateTime createdAt) { + this.id = id; + this.followerId = followerId; + this.followeeId = followeeId; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/example/gamemate/domain/follow/entity/Follow.java b/src/main/java/com/example/gamemate/domain/follow/entity/Follow.java new file mode 100644 index 0000000..837a819 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/entity/Follow.java @@ -0,0 +1,30 @@ +package com.example.gamemate.domain.follow.entity; + +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseCreatedEntity; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class Follow extends BaseCreatedEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "follower_id", nullable = false) + private User follower; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "followee_id", nullable = false) + private User followee; + + public Follow() { + } + + public Follow(User follower, User followee) { + this.follower = follower; + this.followee = followee; + } +} diff --git a/src/main/java/com/example/gamemate/domain/follow/repository/FollowRepository.java b/src/main/java/com/example/gamemate/domain/follow/repository/FollowRepository.java new file mode 100644 index 0000000..c8d7441 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/repository/FollowRepository.java @@ -0,0 +1,32 @@ +package com.example.gamemate.domain.follow.repository; + +import com.example.gamemate.domain.follow.dto.FollowFindResponseDto; +import com.example.gamemate.domain.follow.entity.Follow; +import com.example.gamemate.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface FollowRepository extends JpaRepository { + Boolean existsByFollowerAndFollowee(User follower, User followee); + List findByFollowee(User followee); + List findByFollower(User follower); + + @Query("SELECT NEW com.example.gamemate.domain.follow.dto.FollowFindResponseDto(f.follower.id, f.follower.nickname) " + + "FROM Follow f " + + "JOIN f.follower " + + "WHERE f.followee.email = :email " + + "AND f.follower.userStatus != 'WITHDRAW'") + List findFollowersByFolloweeEmail(@Param("email") String email); + + @Query("SELECT NEW com.example.gamemate.domain.follow.dto.FollowFindResponseDto(f.followee.id, f.followee.nickname) " + + "FROM Follow f " + + "JOIN f.followee " + + "WHERE f.follower.email = :email " + + "AND f.followee.userStatus != 'WITHDRAW'") + List findFollowingByFollowerEmail(@Param("email") String email); +} diff --git a/src/main/java/com/example/gamemate/domain/follow/service/FollowService.java b/src/main/java/com/example/gamemate/domain/follow/service/FollowService.java new file mode 100644 index 0000000..f5ce6bf --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/follow/service/FollowService.java @@ -0,0 +1,153 @@ +package com.example.gamemate.domain.follow.service; + +import com.example.gamemate.domain.follow.dto.*; +import com.example.gamemate.domain.follow.entity.Follow; +import com.example.gamemate.domain.follow.repository.FollowRepository; +import com.example.gamemate.domain.notification.service.NotificationService; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.UserStatus; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.eventListener.event.FollowCreatedEvent; +import com.example.gamemate.global.exception.ApiException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; + + +/** + * 팔로우 기능을 처리하는 서비스 클래스입니다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FollowService { + private final UserRepository userRepository; + private final FollowRepository followRepository; + private final NotificationService notificationService; + private final ApplicationEventPublisher publisher; + + + /** + * 사용자 간의 팔로우를 생성합니다. + * @param dto FollowCreateRequestDto 팔로우할 상대방의 email + * @param loginUser 현재 인증된 사용자 정보 + * @return 팔로우 처리 결과를 담은 FollowResponseDto + */ + @Transactional + public FollowResponseDto createFollow(FollowCreateRequestDto dto, User loginUser) { + + User followee = userRepository.findByEmail(dto.getEmail()) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if (followee.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } // 탈퇴한 유저일때 예외처리 + + if (followRepository.existsByFollowerAndFollowee(loginUser, followee)) { + throw new ApiException(ErrorCode.IS_ALREADY_FOLLOWED); + } // 이미 팔로우를 했을때 예외처리 + + if (Objects.equals(loginUser.getEmail(), dto.getEmail())) { + throw new ApiException(ErrorCode.INVALID_INPUT); + } // 자기 자신을 팔로우 할때 예외처리 + + Follow follow = new Follow(loginUser, followee); + followRepository.save(follow); + publisher.publishEvent(new FollowCreatedEvent(this, follow)); + + return new FollowResponseDto( + follow.getId(), + follow.getFollower().getId(), + follow.getFollowee().getId(), + follow.getCreatedAt() + ); + } + + /** + * 사용자 간의 팔로우를 취소합니다. + * @param id 팔로우 id + * @param loginUser 현재 인증된 사용자 정보 + */ + @Transactional + public void deleteFollow(Long id, User loginUser) { + + Follow findFollow = followRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.FOLLOW_NOT_FOUND)); + + log.info("사용자 email : {}" , loginUser.getEmail()); + + if (!Objects.equals(findFollow.getFollower().getId(), loginUser.getId())) { + throw new ApiException(ErrorCode.INVALID_INPUT); + } // 본인의 팔로우가 아닐때 예외처리 + + followRepository.delete(findFollow); + } + + /** + * 팔로우 상태를 확인합니다. (loginUser 가 followee 를 팔로우 했는지 확인) + * @param loginUser 현재 인증된 사용자 정보 + * @param email 팔로우 상태를 확인할 사용자 email + * @return 팔로우 상태의 정보를 담은 FollowBooleanResponseDto + */ + public FollowBooleanResponseDto findFollow(User loginUser, String email) { + + User followee = userRepository.findByEmail(email) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if (followee.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } // 확인할 상대방이 탈퇴한 회원일때 예외처리 + + if (!followRepository.existsByFollowerAndFollowee(loginUser, followee)) { + return new FollowBooleanResponseDto( + false, + loginUser.getId(), + followee.getId() + ); + } + + return new FollowBooleanResponseDto( + true, + loginUser.getId(), + followee.getId() + ); + } + + /** + * 특정 유저의 팔로워 목록를 조회합니다. + * @param email 팔로워 목록을 확인할 상대방 email + * @return 팔로워 목록을 담은 List + */ + public List findFollowers(String email) { + User followee = userRepository.findByEmail(email) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if (followee.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } // 확인할 상대방이 탈퇴한 회원일때 예외처리 + + return followRepository.findFollowersByFolloweeEmail(email); + } + + /** + * 특정 유저의 팔로잉 목록를 조회합니다. + * @param email 팔로잉 목록을 조회할 상대방 email + * @return 팔로잉 목록을 담은 List + */ + public List findFollowing(String email) { + User follower = userRepository.findByEmail(email) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if (follower.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } // 확인할 상대방이 탈퇴한 회원일때 예외처리 + + return followRepository.findFollowingByFollowerEmail(email); + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/controller/GameController.java b/src/main/java/com/example/gamemate/domain/game/controller/GameController.java new file mode 100644 index 0000000..535d333 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/controller/GameController.java @@ -0,0 +1,151 @@ +package com.example.gamemate.domain.game.controller; + +import com.example.gamemate.domain.game.dto.request.GameCreateRequestDto; +import com.example.gamemate.domain.game.dto.request.GameUpdateRequestDto; +import com.example.gamemate.domain.game.dto.response.GameCreateResponseDto; +import com.example.gamemate.domain.game.dto.response.GameFindAllResponseDto; +import com.example.gamemate.domain.game.dto.response.GameFindByIdResponseDto; +import com.example.gamemate.domain.game.service.GameService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 게임 관련 API를 처리하는 컨트롤러 클래스입니다. + * 게임의 생성, 조회, 수정, 삭제 기능을 제공합니다. + */ +@RestController +@RequestMapping("/games") +@Slf4j +@RequiredArgsConstructor +public class GameController { + private final GameService gameService; + + /** + * 새로운 게임을 생성합니다. + * + * @param gameDataString 게임 데이터를 포함한 JSON 문자열 + * @param file 게임 관련 이미지 파일 (선택적) + * @param customUserDetails 인증된 사용자 정보 + * @return 생성된 게임 정보를 포함한 ResponseEntity + * @throws RuntimeException JSON 파싱 오류 시 발생 + */ + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createGame( + @Valid @RequestPart(value = "gameData") String gameDataString, + @RequestPart(value = "file", required = false) MultipartFile file, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + ObjectMapper mapper = new ObjectMapper(); + GameCreateRequestDto requestDto; + try { + requestDto = mapper.readValue(gameDataString, GameCreateRequestDto.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("Invalid JSON format", e); + } + + GameCreateResponseDto responseDto = gameService.createGame(customUserDetails.getUser(), requestDto, file); + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + /** + * 모든 게임을 페이지네이션하여 조회하거나 검색합니다. + * + * @param keyword 검색 키워드 (선택적) + * @param genre 게임 장르 (선택적) + * @param platform 게임 플랫폼 (선택적) + * @param page 페이지 번호 (기본값: 0) + * @param size 페이지 크기 (기본값: 10) + * @return 게임 목록을 포함한 ResponseEntity + */ + @GetMapping + public ResponseEntity> findAllGame( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String genre, + @RequestParam(required = false) String platform, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + + log.info("Search parameters - keyword: {}, genre: {}, platform: {}, page: {}, size: {}", + keyword, genre, platform, page, size); + Page games; + if (keyword != null || genre != null || platform != null) { + games = gameService.searchGame(keyword, genre, platform, page, size); + } else { + games = gameService.findAllGame(page, size); + } + + return new ResponseEntity<>(games, HttpStatus.OK); + + } + + /** + * 특정 ID의 게임을 조회합니다. + * + * @param id 조회할 게임의 ID + * @return 조회된 게임 정보를 포함한 ResponseEntity + */ + @GetMapping("/{id}") + public ResponseEntity findGameById( + @PathVariable Long id) { + + GameFindByIdResponseDto gameById = gameService.findGameById(id); + return new ResponseEntity<>(gameById, HttpStatus.OK); + + } + + /** + * 특정 ID의 게임 정보를 수정합니다. + * + * @param id 수정할 게임의 ID + * @param gameDataString 수정할 게임 데이터를 포함한 JSON 문자열 + * @param newFile 새로운 게임 이미지 파일 (선택적) + * @param customUserDetails 인증된 사용자 정보 + * @return 수정 결과를 나타내는 ResponseEntity + * @throws RuntimeException JSON 파싱 오류 시 발생 + */ + @PatchMapping("/{id}") + public ResponseEntity updateGame( + @PathVariable Long id, + @Valid @RequestPart(value = "gameData") String gameDataString, + @RequestPart(value = "file", required = false) MultipartFile newFile, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + ObjectMapper mapper = new ObjectMapper(); + GameUpdateRequestDto requestDto; + try { + requestDto = mapper.readValue(gameDataString, GameUpdateRequestDto.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("Invalid JSON format", e); + } + + gameService.updateGame(id, requestDto, newFile, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 특정 ID의 게임을 삭제합니다. + * + * @param id 삭제할 게임의 ID + * @param customUserDetails 인증된 사용자 정보 + * @return 삭제 결과를 나타내는 ResponseEntity + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteGame( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + gameService.deleteGame(id, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/controller/GameEnrollRequestController.java b/src/main/java/com/example/gamemate/domain/game/controller/GameEnrollRequestController.java new file mode 100644 index 0000000..a321d55 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/controller/GameEnrollRequestController.java @@ -0,0 +1,104 @@ +package com.example.gamemate.domain.game.controller; + +import com.example.gamemate.domain.game.dto.request.GameEnrollRequestCreateRequestDto; +import com.example.gamemate.domain.game.dto.response.GameEnrollRequestResponseDto; +import com.example.gamemate.domain.game.dto.request.GameEnrollRequestUpdateRequestDto; +import com.example.gamemate.domain.game.service.GameEnrollRequestService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * 게임 등록 요청 관련 API를 처리하는 컨트롤러 클래스입니다. + * 게임 등록 요청의 생성, 조회, 수정, 삭제 기능을 제공합니다. + */ +@RestController +@RequestMapping("/games/requests") +@RequiredArgsConstructor +public class GameEnrollRequestController { + private final GameEnrollRequestService gameEnrollRequestService; + + /** + * 새로운 게임 등록 요청을 생성합니다. + * + * @param requestDto 게임 등록 요청 데이터 + * @param customUserDetails 인증된 사용자 정보 + * @return 생성된 게임 등록 요청 정보를 포함한 ResponseEntity + */ + @PostMapping + public ResponseEntity CreateGameEnrollRequest( + @RequestBody GameEnrollRequestCreateRequestDto requestDto, + @Valid @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + GameEnrollRequestResponseDto responseDto = gameEnrollRequestService.createGameEnrollRequest(requestDto, customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + /** + * 모든 게임 등록 요청을 조회합니다. + * + * @param customUserDetails 인증된 사용자 정보 + * @return 게임 등록 요청 목록을 포함한 ResponseEntity + */ + @GetMapping + public ResponseEntity> findAllGameEnrollRequest( + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Page gameEnrollRequestAll = gameEnrollRequestService.findAllGameEnrollRequest(customUserDetails.getUser()); + return new ResponseEntity<>(gameEnrollRequestAll, HttpStatus.OK); + + } + + /** + * 모든 게임 등록 요청을 조회합니다. + * + * @param customUserDetails 인증된 사용자 정보 + * @return 게임 등록 요청 목록을 포함한 ResponseEntity + */ + @GetMapping("/{id}") + public ResponseEntity findGameEnrollRequestById( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + GameEnrollRequestResponseDto gameEnrollRequestById = gameEnrollRequestService.findGameEnrollRequestById(id, customUserDetails.getUser()); + return new ResponseEntity<>(gameEnrollRequestById, HttpStatus.OK); + } + + /** + * 특정 ID의 게임 등록 요청을 수정하고, 필요시 게임 등록 기능과 연계합니다. + * + * @param id 수정할 게임 등록 요청의 ID + * @param requestDto 수정할 게임 등록 요청 데이터 + * @param customUserDetails 인증된 사용자 정보 + * @return 수정 결과를 나타내는 ResponseEntity + */ + @PatchMapping("/{id}") + public ResponseEntity updateGameEnroll( + @PathVariable Long id, + @Valid @RequestBody GameEnrollRequestUpdateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + gameEnrollRequestService.updateGameEnroll(id, requestDto, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 특정 ID의 게임 등록 요청을 삭제합니다. + * + * @param id 삭제할 게임 등록 요청의 ID + * @param customUserDetails 인증된 사용자 정보 + * @return 삭제 결과를 나타내는 ResponseEntity + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteGameEnroll( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + gameEnrollRequestService.deleteGameEnroll(id,customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/controller/GameRecommendContorller.java b/src/main/java/com/example/gamemate/domain/game/controller/GameRecommendContorller.java new file mode 100644 index 0000000..6d3d99d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/controller/GameRecommendContorller.java @@ -0,0 +1,59 @@ +package com.example.gamemate.domain.game.controller; + +import com.example.gamemate.domain.game.dto.response.GameRecommendHistorysResponseDto; +import com.example.gamemate.domain.game.dto.request.UserGamePreferenceRequestDto; +import com.example.gamemate.domain.game.dto.response.UserGamePreferenceResponseDto; +import com.example.gamemate.domain.game.service.GameRecommendService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * 게임 추천 관련 API를 처리하는 컨트롤러 클래스입니다. + * 사용자 게임 선호도 생성 및 게임 추천 이력 조회 기능을 제공합니다. + */ +@RestController +@RequestMapping("/games/recommendations") +@RequiredArgsConstructor +public class GameRecommendContorller { + + private final GameRecommendService gameRecommendService; + + /** + * 사용자의 게임 선호도를 생성합니다. + * + * @param requestDto 사용자 게임 선호도 정보 + * @param customUserDetails 인증된 사용자 정보 + * @return 생성된 사용자 게임 선호도 정보를 포함한 ResponseEntity + */ + @PostMapping + public ResponseEntity createUserGamePreference( + @RequestBody UserGamePreferenceRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + UserGamePreferenceResponseDto responseDto = gameRecommendService.createUserGamePreference(requestDto, customUserDetails.getUser()); + + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + /** + * 특정 사용자의 게임 추천 이력을 조회합니다. + * + * @param customUserDetails 인증된 사용자 정보 + * @return 게임 추천 이력 목록을 포함한 ResponseEntity + */ + @GetMapping + public ResponseEntity> getGameRecommendHistories( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + Page responseDto = gameRecommendService.getGameRecommendHistories(customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/ChatRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/ChatRequestDto.java new file mode 100644 index 0000000..e0df5af --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/request/ChatRequestDto.java @@ -0,0 +1,50 @@ +package com.example.gamemate.domain.game.dto.request; + +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ChatRequestDto { + private List contents; + private GenerationConfig generationConfig; + + @Getter + @Setter + public static class Content { + private Parts parts; + } + + @Getter @Setter + public static class Parts { + private String text; + + } + + @Getter @Setter + public static class GenerationConfig { + private int candidate_count; + private int max_output_tokens; + private double temperature; + + } + + public ChatRequestDto(String prompt) { + this.contents = new ArrayList<>(); + Content content = new Content(); + Parts parts = new Parts(); + + parts.setText(prompt); + content.setParts(parts); + + this.contents.add(content); + this.generationConfig = new GenerationConfig(); + this.generationConfig.setCandidate_count(1); + this.generationConfig.setMax_output_tokens(1000); + this.generationConfig.setTemperature(0.7); + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameCreateRequestDto.java new file mode 100644 index 0000000..c579fc4 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameCreateRequestDto.java @@ -0,0 +1,24 @@ +package com.example.gamemate.domain.game.dto.request; + +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@NoArgsConstructor +public class GameCreateRequestDto { + private String title; + private String genre; + private String platform; + private String description; + + + public GameCreateRequestDto(String title, String genre, String platform, String description) { + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + + } + +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestCreateRequestDto.java new file mode 100644 index 0000000..cc09aa5 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestCreateRequestDto.java @@ -0,0 +1,21 @@ +package com.example.gamemate.domain.game.dto.request; + +import lombok.Getter; + +@Getter + +public class GameEnrollRequestCreateRequestDto { + private String title; + private String genre; + private String platform; + private String description; + + + public GameEnrollRequestCreateRequestDto(String title, String genre, String platform, String description) { + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestUpdateRequestDto.java new file mode 100644 index 0000000..f65e42a --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameEnrollRequestUpdateRequestDto.java @@ -0,0 +1,23 @@ +package com.example.gamemate.domain.game.dto.request; + +import lombok.Getter; + +@Getter + +public class GameEnrollRequestUpdateRequestDto { + private String title; + private String genre; + private String platform; + private String description; + private Boolean isAccepted; + + + public GameEnrollRequestUpdateRequestDto(String title, String genre, String platform, String description, Boolean isAccepted) { + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + this.isAccepted = isAccepted; + + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/GameUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/GameUpdateRequestDto.java new file mode 100644 index 0000000..1919372 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/request/GameUpdateRequestDto.java @@ -0,0 +1,21 @@ +package com.example.gamemate.domain.game.dto.request; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GameUpdateRequestDto { + private String title; + private String genre; + private String platform; + private String description; + + + public GameUpdateRequestDto(String title, String genre, String platform, String description) { + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/request/UserGamePreferenceRequestDto.java b/src/main/java/com/example/gamemate/domain/game/dto/request/UserGamePreferenceRequestDto.java new file mode 100644 index 0000000..6036872 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/request/UserGamePreferenceRequestDto.java @@ -0,0 +1,19 @@ +package com.example.gamemate.domain.game.dto.request; + +import com.example.gamemate.domain.user.entity.User; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserGamePreferenceRequestDto { + + private User user; + private String preferredGenres; + private String playStyle; + private String playTime; + private String difficulty; + private String platform; + private String extraRequest; + +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/ChatResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/ChatResponseDto.java new file mode 100644 index 0000000..3dae15e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/ChatResponseDto.java @@ -0,0 +1,56 @@ +package com.example.gamemate.domain.game.dto.response; + +import lombok.*; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatResponseDto { + + private List candidates; + private PromptFeedback promptFeedback; + + @Getter + @Setter + public static class Candidate { + private Content content; + private String finishReason; + private int index; + private List safetyRatings; + + } + + @Getter + @Setter + @ToString + public static class Content { + private List parts; + private String role; + + } + + @Getter + @Setter + @ToString + public static class Parts { + private String text; + + } + + @Getter + @Setter + public static class SafetyRating { + private String category; + private String probability; + } + + @Getter + @Setter + public static class PromptFeedback { + private List safetyRatings; + + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameCreateResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameCreateResponseDto.java new file mode 100644 index 0000000..02214b9 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameCreateResponseDto.java @@ -0,0 +1,34 @@ +package com.example.gamemate.domain.game.dto.response; + +import com.example.gamemate.domain.game.entity.Game; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class GameCreateResponseDto { + private Long id; + private String title; + private String genre; + private String platform; + private String description; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; + private final String fileName; + private final String imageUrl; + + public GameCreateResponseDto(Game game) { + + this.id = game.getId(); + this.title = game.getTitle(); + this.genre = game.getGenre(); + this.platform = game.getPlatform(); + this.description = game.getDescription(); + this.createdAt = game.getCreatedAt(); + this.modifiedAt = game.getModifiedAt(); + this.fileName = game.getImages().isEmpty() ? null : + game.getImages().get(0).getFileName(); + this.imageUrl = game.getImages().isEmpty() ? null : + game.getImages().get(0).getFilePath(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameEnrollRequestResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameEnrollRequestResponseDto.java new file mode 100644 index 0000000..eae671b --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameEnrollRequestResponseDto.java @@ -0,0 +1,35 @@ +package com.example.gamemate.domain.game.dto.response; + +import com.example.gamemate.domain.game.entity.GamaEnrollRequest; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class GameEnrollRequestResponseDto { + private final String message; + private Long id; + private String title; + private String genre; + private String platform; + private String description; + private LocalDateTime createdAt; + private LocalDateTime modifiedAt; + private boolean isAccepted; + private Long userId; + + public GameEnrollRequestResponseDto(GamaEnrollRequest gameEnrollRequest) { + // game 객체의 필드들을 이용해 DTO의 필드들을 초기화 + this.message = "게임등록 요청이 완료되었습니다."; + this.id = gameEnrollRequest.getId(); + this.title = gameEnrollRequest.getTitle(); + this.genre = gameEnrollRequest.getGenre(); + this.platform = gameEnrollRequest.getPlatform(); + this.description = gameEnrollRequest.getDescription(); + this.createdAt = gameEnrollRequest.getCreatedAt(); + this.modifiedAt = gameEnrollRequest.getModifiedAt(); + this.isAccepted = gameEnrollRequest.getIsAccepted(); + this.userId = gameEnrollRequest.getUser().getId(); + + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindAllResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindAllResponseDto.java new file mode 100644 index 0000000..15d342d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindAllResponseDto.java @@ -0,0 +1,46 @@ +package com.example.gamemate.domain.game.dto.response; + +import com.example.gamemate.domain.game.entity.Game; +import com.example.gamemate.domain.review.entity.Review; +import lombok.Getter; + +import java.util.List; + +@Getter +public class GameFindAllResponseDto { + private final Long id; + private final String title; + private final String genre; + private final String platform; + private final Long reviewCount; + private final Double averageStar; + private final String fileName; + private final String imageUrl; + + public GameFindAllResponseDto(Game game) { + // game 객체의 필드들을 이용해 DTO의 필드들을 초기화 + this.id = game.getId(); + this.title = game.getTitle(); + this.genre = game.getGenre(); + this.platform = game.getPlatform(); + this.reviewCount = (long) game.getReviews().size(); + this.averageStar = calculateAverageStar(game.getReviews()); + this.fileName = game.getImages().isEmpty() ? null : + game.getImages().get(0).getFileName(); + this.imageUrl = game.getImages().isEmpty() ? null : + game.getImages().get(0).getFilePath(); + } + + private Double calculateAverageStar(List reviews) { + if (reviews.isEmpty()) { + return 0.0; + } + double average = reviews.stream() + .mapToInt(Review::getStar) + .average() + .orElse(0.0); + + // 소수점 둘째 자리에서 반올림 + return Math.round(average * 10.0) / 10.0; + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindByIdResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindByIdResponseDto.java new file mode 100644 index 0000000..a8ab3a1 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameFindByIdResponseDto.java @@ -0,0 +1,37 @@ +package com.example.gamemate.domain.game.dto.response; + +import com.example.gamemate.domain.game.entity.Game; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@JsonPropertyOrder({"id", "title", "genre", "platform", "description", "createdAt", "fileName", "imageUrl", "modifiedAt"}) +public class GameFindByIdResponseDto { + private final Long id; + private final String title; + private final String genre; + private final String platform; + private final String description; + private final LocalDateTime createdAt; + private final LocalDateTime modifiedAt; + private final String fileName; + private final String imageUrl; +// private final List reviews; + + public GameFindByIdResponseDto(Game game) { + // game 객체의 필드들을 이용해 DTO의 필드들을 초기화 + this.id = game.getId(); + this.title = game.getTitle(); + this.genre = game.getGenre(); + this.platform = game.getPlatform(); + this.description = game.getDescription(); + this.createdAt = game.getCreatedAt(); + this.modifiedAt = game.getModifiedAt(); + this.fileName = game.getImages().isEmpty() ? null : + game.getImages().get(0).getFileName(); + this.imageUrl = game.getImages().isEmpty() ? null : + game.getImages().get(0).getFilePath(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendHistorysResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendHistorysResponseDto.java new file mode 100644 index 0000000..36e5338 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendHistorysResponseDto.java @@ -0,0 +1,26 @@ +package com.example.gamemate.domain.game.dto.response; + +import com.example.gamemate.domain.game.entity.GameRecommendHistory; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GameRecommendHistorysResponseDto { + private Long userId; + private String title; + private String description; + private Double metacriticScore; + //private Double matchingScore; + private String reasonForRecommendation; + + public GameRecommendHistorysResponseDto(GameRecommendHistory gameRecommendHistory) { + + this.userId = gameRecommendHistory.getUser().getId(); + this.title = gameRecommendHistory.getTitle(); + this.description = gameRecommendHistory.getDescription(); + this.metacriticScore = gameRecommendHistory.getMetacriticScore(); + //this.matchingScore = gameRecommendHistory.getMatchingScore(); + this.reasonForRecommendation = gameRecommendHistory.getReasonForRecommendation(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendationResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendationResponseDto.java new file mode 100644 index 0000000..4230c53 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameRecommendationResponseDto.java @@ -0,0 +1,17 @@ +package com.example.gamemate.domain.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class GameRecommendationResponseDto { + private String title; + private String description; + private Double metacriticScore; + //private Double matchingScore; + private String reasonForRecommendation; +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/GameUpdateResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/GameUpdateResponseDto.java new file mode 100644 index 0000000..ed33b63 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/GameUpdateResponseDto.java @@ -0,0 +1,26 @@ +package com.example.gamemate.domain.game.dto.response; + +import com.example.gamemate.domain.game.entity.Game; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class GameUpdateResponseDto { + private Long id; + private String title; + private String genre; + private String platform; + private String description; + private final LocalDateTime modifiedAt; + + public GameUpdateResponseDto(Game game) { + // game 객체의 필드들을 이용해 DTO의 필드들을 초기화 + this.id = game.getId(); + this.title = game.getTitle(); + this.genre = game.getGenre(); + this.platform = game.getPlatform(); + this.description = game.getDescription(); + this.modifiedAt = game.getModifiedAt(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/game/dto/response/UserGamePreferenceResponseDto.java b/src/main/java/com/example/gamemate/domain/game/dto/response/UserGamePreferenceResponseDto.java new file mode 100644 index 0000000..06228cb --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/dto/response/UserGamePreferenceResponseDto.java @@ -0,0 +1,31 @@ +package com.example.gamemate.domain.game.dto.response; + +import com.example.gamemate.domain.game.entity.UserGamePreference; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +@Getter +public class UserGamePreferenceResponseDto { + + private Long user; + private String preferredGenres; + private String playStyle; + private String playTime; + private String difficulty; + private String platform; + private List recommendations; + + public UserGamePreferenceResponseDto(UserGamePreference userGamePreference,List recommendations) { + this.user = userGamePreference.getUser().getId(); + this.preferredGenres = userGamePreference.getPreferredGenres(); + this.playStyle = userGamePreference.getPlayStyle(); + this.playTime = userGamePreference.getPlayTime(); + this.difficulty = userGamePreference.getDifficulty(); + this.platform = userGamePreference.getPlatform(); + this.recommendations =recommendations; + } + +} diff --git a/src/main/java/com/example/gamemate/domain/game/entity/GamaEnrollRequest.java b/src/main/java/com/example/gamemate/domain/game/entity/GamaEnrollRequest.java new file mode 100644 index 0000000..d353f51 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/entity/GamaEnrollRequest.java @@ -0,0 +1,57 @@ +package com.example.gamemate.domain.game.entity; + +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "game_enroll_request") +@NoArgsConstructor +public class GamaEnrollRequest extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "title", nullable = false, length = 255, unique = false) + private String title; + + @Column(name = "genre", length = 10) + private String genre; + + @Column(name = "description", length = 255) + private String description; + + @Column(name = "platform", length = 20) + private String platform; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "is_accepted", columnDefinition = "BOOLEAN DEFAULT false") + private Boolean isAccepted = false; + + public GamaEnrollRequest(String title, String genre, String platform, String description, User userId ) { + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + this.user = userId; + + } + + public void updateGameEnroll(String title, String genre, String platform, String description,Boolean isAccepted) { + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + this.isAccepted = isAccepted; + } + + +} diff --git a/src/main/java/com/example/gamemate/domain/game/entity/Game.java b/src/main/java/com/example/gamemate/domain/game/entity/Game.java new file mode 100644 index 0000000..a695b0e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/entity/Game.java @@ -0,0 +1,67 @@ +package com.example.gamemate.domain.game.entity; + +import com.example.gamemate.domain.review.entity.Review; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "game") +@NoArgsConstructor +public class Game extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "title", nullable = false, length = 255 ,unique = false) + private String title; + + @Column(name = "genre", nullable = false, length = 10) + private String genre; + + @Column(name = "description", nullable = false, length = 255) + private String description; + + @Column(name = "platform", nullable = false, length = 20) + private String platform; + + @OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true) + private List images = new ArrayList<>(); + + @OneToMany(mappedBy = "game", fetch = FetchType.LAZY) + private List reviews = new ArrayList<>(); + + public Game(String title, String genre, String platform, String description) { + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + } + + public void updateGame(String title, String genre, String platform, String description){ + this.title = title; + this.genre = genre; + this.platform = platform; + this.description = description; + } + + public void addImage(GameImage gameImage) { + this.images.add(gameImage); + } + + public void removeGameImage(GameImage gameImage) { + this.images.remove(gameImage); + } + + public void clearImages() { + this.images.clear(); + } + + + +} diff --git a/src/main/java/com/example/gamemate/domain/game/entity/GameImage.java b/src/main/java/com/example/gamemate/domain/game/entity/GameImage.java new file mode 100644 index 0000000..4a5c0c5 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/entity/GameImage.java @@ -0,0 +1,41 @@ +package com.example.gamemate.domain.game.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "game_image") +public class GameImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "game_id") + private Game game; + + @Column(name = "file_name", nullable = false) + private String fileName; + + @Column(name = "file_type", nullable = false) + private String fileType; + + @Column(name = "file_path", nullable = false) + private String filePath; + + public GameImage(String fileName, String fileType, String filePath, Game game) { + this.fileName = fileName; + this.fileType = fileType; + this.filePath = filePath; + this.game = game; + if (game != null) { + game.getImages().add(this); + } + } + +} diff --git a/src/main/java/com/example/gamemate/domain/game/entity/GameRecommendHistory.java b/src/main/java/com/example/gamemate/domain/game/entity/GameRecommendHistory.java new file mode 100644 index 0000000..d2985d5 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/entity/GameRecommendHistory.java @@ -0,0 +1,51 @@ +package com.example.gamemate.domain.game.entity; + +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "game_recommend_history") +public class GameRecommendHistory extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(length = 255) + private String title; + + @Column(length = 255) + private String description; + + //private Double matchingScore; + + @Column(length = 255) + private String reasonForRecommendation; + + private Double metacriticScore; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "preferences_id") + private UserGamePreference userGamePreference; + + public GameRecommendHistory(User user, String title, String description, String reasonForRecommendation, Double metacriticScore, UserGamePreference userGamePreference) { + this.user = user; + this.title = title; + this.description = description; + //this.matchingScore = matchingScore; + this.reasonForRecommendation = reasonForRecommendation; + this.metacriticScore = metacriticScore; + this.userGamePreference = userGamePreference; + + } +} + diff --git a/src/main/java/com/example/gamemate/domain/game/entity/UserGamePreference.java b/src/main/java/com/example/gamemate/domain/game/entity/UserGamePreference.java new file mode 100644 index 0000000..11526e7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/entity/UserGamePreference.java @@ -0,0 +1,51 @@ +package com.example.gamemate.domain.game.entity; + +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_game_preference") +public class UserGamePreference extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(length = 10) + private String preferredGenres; + + @Column(length = 255) + private String playStyle; + + @Column(length = 10) + private String playTime; + + @Column(length = 10) + private String difficulty; + + @Column(length = 20) + private String platform; + + @Column(length = 255) + private String extraRequest; + + public UserGamePreference(User user, String preferredGenres, String playStyle, String playTime, String difficulty, String platform, String extraRequest){ + this.user =user; + this.preferredGenres = preferredGenres; + this.playStyle = playStyle; + this.playTime = playTime; + this.difficulty = difficulty; + this.platform = platform; + this.extraRequest =extraRequest; + } +} + diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameEnrollRequestRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameEnrollRequestRepository.java new file mode 100644 index 0000000..0e4ec8d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/repository/GameEnrollRequestRepository.java @@ -0,0 +1,11 @@ +package com.example.gamemate.domain.game.repository; + +import com.example.gamemate.domain.game.entity.GamaEnrollRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GameEnrollRequestRepository extends JpaRepository { + + List findByIsAccepted(Boolean isAccepted); +} diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameImageRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameImageRepository.java new file mode 100644 index 0000000..ae82e26 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/repository/GameImageRepository.java @@ -0,0 +1,11 @@ +package com.example.gamemate.domain.game.repository; + +import com.example.gamemate.domain.game.entity.GameImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GameImageRepository extends JpaRepository { + // 게임별 이미지 찾기 + List findGameImagesByGameId(Long gameId); +} diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameRecommendHistoryRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameRecommendHistoryRepository.java new file mode 100644 index 0000000..931904c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/repository/GameRecommendHistoryRepository.java @@ -0,0 +1,10 @@ +package com.example.gamemate.domain.game.repository; + +import com.example.gamemate.domain.game.entity.GameRecommendHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GameRecommendHistoryRepository extends JpaRepository { + Page findByUserId(Long userId, Pageable pageable); +} diff --git a/src/main/java/com/example/gamemate/domain/game/repository/GameRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/GameRepository.java new file mode 100644 index 0000000..79ffa82 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/repository/GameRepository.java @@ -0,0 +1,30 @@ +package com.example.gamemate.domain.game.repository; + +import com.example.gamemate.domain.game.entity.Game; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface GameRepository extends JpaRepository { + + // 아이디로 게임 찾기 + Optional findGameById(Long id); + + // 게임 검색 + @Query("SELECT g FROM Game g WHERE " + + "(:keyword IS NULL OR " + + "LOWER(g.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(g.description) LIKE LOWER(CONCAT('%', :keyword, '%'))) " + + "AND (:genre IS NULL OR :genre = '' OR LOWER(g.genre) LIKE LOWER(CONCAT('%', :genre, '%'))) " + + "AND (:platform IS NULL OR :platform = '' OR LOWER(g.platform) LIKE LOWER(CONCAT('%', :platform, '%')))") + Page searchGames( + @Param("keyword") String keyword, + @Param("genre") String genre, + @Param("platform") String platform, + Pageable pageable + ); +} diff --git a/src/main/java/com/example/gamemate/domain/game/repository/UserGamePreferenceRepository.java b/src/main/java/com/example/gamemate/domain/game/repository/UserGamePreferenceRepository.java new file mode 100644 index 0000000..d66a245 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/repository/UserGamePreferenceRepository.java @@ -0,0 +1,7 @@ +package com.example.gamemate.domain.game.repository; + +import com.example.gamemate.domain.game.entity.UserGamePreference; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserGamePreferenceRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/gamemate/domain/game/service/GameEnrollRequestService.java b/src/main/java/com/example/gamemate/domain/game/service/GameEnrollRequestService.java new file mode 100644 index 0000000..3e2052c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/service/GameEnrollRequestService.java @@ -0,0 +1,140 @@ +package com.example.gamemate.domain.game.service; + +import com.example.gamemate.domain.game.dto.request.GameEnrollRequestCreateRequestDto; +import com.example.gamemate.domain.game.dto.response.GameEnrollRequestResponseDto; +import com.example.gamemate.domain.game.dto.request.GameEnrollRequestUpdateRequestDto; + +import com.example.gamemate.domain.game.entity.GamaEnrollRequest; +import com.example.gamemate.domain.game.entity.Game; +import com.example.gamemate.domain.game.repository.GameEnrollRequestRepository; +import com.example.gamemate.domain.game.repository.GameRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.Role; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@Slf4j +@RequiredArgsConstructor +public class GameEnrollRequestService { + private final GameRepository gameRepository; + private final GameEnrollRequestRepository gameEnrollRequestRepository; + + /** + * 새로운 게임 등록 요청을 생성합니다. + * @param requestDto 게임 등록 요청 정보를 담은 DTO + * @param userId 요청을 생성하는 사용자 + * @return 생성된 게임 등록 요청 정보 + */ + @Transactional + public GameEnrollRequestResponseDto createGameEnrollRequest(GameEnrollRequestCreateRequestDto requestDto, User userId) { + GamaEnrollRequest gameEnrollRequest = new GamaEnrollRequest( + requestDto.getTitle(), + requestDto.getGenre(), + requestDto.getPlatform(), + requestDto.getDescription(), + userId + ); + GamaEnrollRequest saveEnrollRequest = gameEnrollRequestRepository.save(gameEnrollRequest); + return new GameEnrollRequestResponseDto(saveEnrollRequest); + } + + /** + * 모든 게임 등록 요청을 조회합니다. 관리자 권한이 필요합니다. + * @param loginUser 현재 로그인한 사용자 + * @return 게임 등록 요청 목록 (페이지네이션 적용) + */ + public Page findAllGameEnrollRequest(User loginUser) { + + if (!loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + Pageable pageable = PageRequest.of(0, 10); + + return gameEnrollRequestRepository.findAll(pageable).map(GameEnrollRequestResponseDto::new); + } + + /** + * 특정 ID의 게임 등록 요청을 조회합니다. 관리자 권한이 필요합니다. + * @param id 조회할 게임 등록 요청의 ID + * @param loginUser 현재 로그인한 사용자 + * @return 조회된 게임 등록 요청 정보 + */ + public GameEnrollRequestResponseDto findGameEnrollRequestById(Long id, User loginUser) { + + if (!loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + GamaEnrollRequest gamaEnrollRequest = gameEnrollRequestRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + + return new GameEnrollRequestResponseDto(gamaEnrollRequest); + } + + /** + * 게임 등록 요청을 수정하고, 승인 시 게임을 등록합니다. 관리자 권한이 필요합니다. + * @param id 수정할 게임 등록 요청의 ID + * @param requestDto 수정할 게임 등록 요청 정보를 담은 DTO + * @param loginUser 현재 로그인한 사용자 + */ + @Transactional + public void updateGameEnroll(Long id, GameEnrollRequestUpdateRequestDto requestDto, User loginUser) { + + if (!loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + GamaEnrollRequest gamaEnrollRequest = gameEnrollRequestRepository + .findById(id).orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + + gamaEnrollRequest.updateGameEnroll( + requestDto.getTitle(), + requestDto.getGenre(), + requestDto.getPlatform(), + requestDto.getDescription(), + requestDto.getIsAccepted() + ); + + gameEnrollRequestRepository.save(gamaEnrollRequest); + + // IsAccepted = true > 게임등록 + Boolean accepted = requestDto.getIsAccepted(); + if (accepted == true) { + Game game = new Game( + requestDto.getTitle(), + requestDto.getGenre(), + requestDto.getPlatform(), + requestDto.getDescription() + ); + gameRepository.save(game); + } + } + + /** + * 게임 등록 요청을 삭제합니다. 관리자 권한이 필요합니다. + * @param id 삭제할 게임 등록 요청의 ID + * @param loginUser 현재 로그인한 사용자 + */ + @Transactional + public void deleteGameEnroll(Long id, User loginUser) { + + if (!loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + GamaEnrollRequest gamaEnrollRequest = gameEnrollRequestRepository + .findById(id).orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + gameEnrollRequestRepository.delete(gamaEnrollRequest); + } + +} diff --git a/src/main/java/com/example/gamemate/domain/game/service/GameRecommendService.java b/src/main/java/com/example/gamemate/domain/game/service/GameRecommendService.java new file mode 100644 index 0000000..9dd2ac2 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/service/GameRecommendService.java @@ -0,0 +1,130 @@ +package com.example.gamemate.domain.game.service; + +import com.example.gamemate.domain.game.dto.response.GameRecommendHistorysResponseDto; +import com.example.gamemate.domain.game.dto.response.GameRecommendationResponseDto; +import com.example.gamemate.domain.game.dto.request.UserGamePreferenceRequestDto; +import com.example.gamemate.domain.game.dto.response.UserGamePreferenceResponseDto; +import com.example.gamemate.domain.game.entity.GameRecommendHistory; +import com.example.gamemate.domain.game.entity.UserGamePreference; +import com.example.gamemate.domain.game.repository.GameRecommendHistoryRepository; +import com.example.gamemate.domain.game.repository.UserGamePreferenceRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GameRecommendService { + private final UserGamePreferenceRepository userGamePreferenceRepository; + private final UserRepository userRepository; + private final GameRecommendHistoryRepository gameRecommendHistoryRepository; + private final GeminiService geminiService; + + /** + * 사용자의 게임 선호도를 기반으로 게임을 추천하고, 그 결과를 저장합니다. + * @param requestDto 사용자의 게임 선호도 정보를 담은 DTO + * @param loginUser 현재 로그인한 사용자 + * @return 사용자의 게임 선호도와 추천된 게임 목록을 포함한 응답 DTO + */ + @Transactional + public UserGamePreferenceResponseDto createUserGamePreference(UserGamePreferenceRequestDto requestDto, User loginUser) { + + User user = userRepository.findById(loginUser.getId()) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + UserGamePreference userGamePreference = new UserGamePreference( + user, + requestDto.getPreferredGenres(), + requestDto.getPlayStyle(), + requestDto.getPlayTime(), + requestDto.getDifficulty(), + requestDto.getPlatform(), + requestDto.getExtraRequest() + ); + + UserGamePreference saveData = userGamePreferenceRepository.save(userGamePreference); + + String prompt = String.format( + "나에게 맞는 게임 3개 추천해줘 선호하는 장르는 %s이고 플레이 스타일은 %s 정도고 플레이 타임은 %s 정도고 난이도는 %s 그리고 플랫폼은 %s이고 추가적인 요청은 %s 야 " + + "응답은 " + + "한글(영어)로 된 제목(title), " + + "간단한 내용(description)," + + "metacriticScore 점수(metacriticScore)," + + //"나와의 매칭점수(matchingScore)," + + "추천 이유(reasonForRecommendation)를 적어주고 " + + "응답은 순수 JSON 배열로 알려줘", + userGamePreference.getPreferredGenres(), + userGamePreference.getPlayStyle(), + userGamePreference.getPlayTime(), + userGamePreference.getDifficulty(), + userGamePreference.getPlatform(), + userGamePreference.getExtraRequest() + ); + + String recommendation = geminiService.getContents(prompt); + log.info(recommendation); + + + ObjectMapper objectMapper = new ObjectMapper(); + List gameRecommendations; + try { + + String jsonArray = recommendation + .replaceAll("(?s)^.*?```json\\s*", "") // 시작 부분의 ``` + .replaceAll("\\s*```\\s*$", "") // 끝 부분의 ``` + .trim(); + + gameRecommendations = objectMapper.readValue(jsonArray, new TypeReference>() { + }); + } catch (Exception e) { + throw new ApiException(ErrorCode.RECOMMENDATION_NOT_FOUND); + } + + List gameRecommendHistories = new ArrayList<>(); + for (GameRecommendationResponseDto responseDto : gameRecommendations) { + GameRecommendHistory history = new GameRecommendHistory( + loginUser, + responseDto.getTitle(), + responseDto.getDescription(), + //responseDto.getMatchingScore(), + responseDto.getReasonForRecommendation(), + responseDto.getMetacriticScore(), + saveData + ); + gameRecommendHistories.add(history); + } + + gameRecommendHistoryRepository.saveAll(gameRecommendHistories); + + return new UserGamePreferenceResponseDto(saveData, gameRecommendations); + } + + /** + * 로그인한 사용자의 게임 추천 기록을 페이지네이션하여 조회합니다. + * @param loginUser 현재 로그인한 사용자 + * @return 사용자의 게임 추천 기록 목록 (페이지네이션 적용) + */ + public Page getGameRecommendHistories( User loginUser) { + + Pageable pageable = PageRequest.of(0, 15); + Page histories = gameRecommendHistoryRepository.findByUserId(loginUser.getId(), pageable); + + return histories.map(GameRecommendHistorysResponseDto::new); + + } + +} diff --git a/src/main/java/com/example/gamemate/domain/game/service/GameService.java b/src/main/java/com/example/gamemate/domain/game/service/GameService.java new file mode 100644 index 0000000..5379fcf --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/service/GameService.java @@ -0,0 +1,235 @@ +package com.example.gamemate.domain.game.service; + +import com.example.gamemate.domain.game.dto.request.GameCreateRequestDto; +import com.example.gamemate.domain.game.dto.request.GameUpdateRequestDto; +import com.example.gamemate.domain.game.dto.response.GameCreateResponseDto; +import com.example.gamemate.domain.game.dto.response.GameFindAllResponseDto; +import com.example.gamemate.domain.game.dto.response.GameFindByIdResponseDto; +import com.example.gamemate.domain.game.entity.Game; +import com.example.gamemate.domain.game.entity.GameImage; +import com.example.gamemate.domain.game.repository.GameImageRepository; +import com.example.gamemate.domain.game.repository.GameRepository; +import com.example.gamemate.domain.review.repository.ReviewRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.Role; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import com.example.gamemate.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + + +@Service +@Slf4j +@RequiredArgsConstructor +public class GameService { + private final GameRepository gameRepository; + private final S3Service s3Service; + + /** + * 새로운 게임을 생성합니다. 관리자 권한이 필요합니다. + * @param loginUser 로그인한 사용자 정보 + * @param gameCreateRequestDto 게임 생성 요청 데이터 + * @param file 게임 이미지 파일 + * @return 생성된 게임 정보 + */ + @Transactional + public GameCreateResponseDto createGame(User loginUser, GameCreateRequestDto gameCreateRequestDto, MultipartFile file) { + + if (!loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + Game game = new Game( + gameCreateRequestDto.getTitle(), + gameCreateRequestDto.getGenre(), + gameCreateRequestDto.getPlatform(), + gameCreateRequestDto.getDescription() + ); + + if (file != null && !file.isEmpty()) { + try { + + String fileName = file.getOriginalFilename(); + String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); + List allowedExtensions = Arrays.asList("jpg", "jpeg"); + + if (!allowedExtensions.contains(fileExtension)) { + throw new ApiException(ErrorCode.INVALID_FILE_EXTENSION); + } + + String fileUrl = s3Service.uploadFile(file); + GameImage gameImage = new GameImage( + file.getOriginalFilename(), + file.getContentType(), + fileUrl, + game + ); + + game.addImage(gameImage); + } catch (IOException e) { + throw new ApiException(ErrorCode.FILE_UPLOAD_ERROR); + } + + } + + Game savedGame = gameRepository.save(game); + return new GameCreateResponseDto(savedGame); + } + + /** + * 모든 게임을 페이지네이션하여 조회합니다. + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 게임 목록 + */ + public Page findAllGame(int page, int size) { + + Pageable pageable = PageRequest.of(page, size); + return gameRepository.findAll(pageable).map(GameFindAllResponseDto::new); + } + + /** + * 특정 ID의 게임을 조회합니다. + * @param id 게임 ID + * @return 조회된 게임 정보 + * @throws ApiException 게임을 찾을 수 없는 경우 + */ + public GameFindByIdResponseDto findGameById(Long id) { + + Game game = gameRepository.findGameById(id) + .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + + return new GameFindByIdResponseDto(game); + } + + /** + * 게임 정보를 수정합니다. 관리자 권한이 필요합니다. + * @param id 수정할 게임의 ID + * @param requestDto 수정할 게임 정보 + * @param newFile 새로운 게임 이미지 파일 + * @param loginUser 로그인한 사용자 정보 + */ + @Transactional + public void updateGame(Long id, GameUpdateRequestDto requestDto, MultipartFile newFile, User loginUser) { + + if (loginUser == null || !loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + Game game = gameRepository.findGameById(id) + .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + + deleteExistingImages(game); + uploadNewImage(game, newFile); + + game.updateGame( + requestDto.getTitle(), + requestDto.getGenre(), + requestDto.getPlatform(), + requestDto.getDescription() + ); + + gameRepository.save(game); + } + + /** + * 기존 게임 이미지를 삭제합니다. + * @param game 이미지를 삭제할 게임 + */ + private void deleteExistingImages(Game game) { + for (GameImage image : game.getImages()) { + try { + s3Service.deleteFile(image.getFilePath()); + } catch (Exception e) { + log.error("Failed to delete file: {}", image.getFilePath(), e); + } + } + game.getImages().clear(); + } + + /** + * 새로운 게임 이미지를 업로드합니다. + * @param game 이미지를 업로드할 게임 + * @param newFile 새로운 이미지 파일 + */ + private void uploadNewImage(Game game, MultipartFile newFile) { + if (newFile != null && !newFile.isEmpty()) { + try { + + String fileName = newFile.getOriginalFilename(); + String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); + List allowedExtensions = Arrays.asList("jpg", "jpeg"); + + if (!allowedExtensions.contains(fileExtension)) { + throw new ApiException(ErrorCode.INVALID_FILE_EXTENSION); + } + + String fileUrl = s3Service.uploadFile(newFile); + GameImage gameImage = new GameImage( + newFile.getOriginalFilename(), + newFile.getContentType(), + fileUrl, + game + ); + game.addImage(gameImage); + } catch (IOException e) { + throw new ApiException(ErrorCode.FILE_UPLOAD_ERROR); + } + } + } + + /** + * 게임을 삭제합니다. 관리자 권한이 필요합니다. + * @param id 삭제할 게임의 ID + * @param loginUser 로그인한 사용자 정보 + */ + @Transactional + public void deleteGame(Long id, User loginUser) { + + if (!loginUser.getRole().equals(Role.ADMIN)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + Game game = gameRepository.findGameById(id) + .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + + if (!game.getImages().isEmpty()) { + for (GameImage image : game.getImages()) { + s3Service.deleteFile(image.getFilePath()); + } + } + + gameRepository.delete(game); + } + + /** + * 키워드, 장르, 플랫폼으로 게임을 검색합니다. + * @param keyword 검색 키워드 + * @param genre 게임 장르 + * @param platform 게임 플랫폼 + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 검색된 게임 목록 + */ + public Page searchGame(String keyword, String genre, String platform, int page, int size) { + + log.info("Searching games with parameters - keyword: {}, genre: {}, platform: {}", + keyword, genre, platform); + Pageable pageable = PageRequest.of(page, size); + Page games = gameRepository.searchGames(keyword, genre, platform, pageable); + return games.map(GameFindAllResponseDto::new); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/domain/game/service/GeminiService.java b/src/main/java/com/example/gamemate/domain/game/service/GeminiService.java new file mode 100644 index 0000000..509c99a --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/game/service/GeminiService.java @@ -0,0 +1,44 @@ +package com.example.gamemate.domain.game.service; + +import com.example.gamemate.domain.game.dto.request.ChatRequestDto; +import com.example.gamemate.domain.game.dto.response.ChatResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +public class GeminiService { + @Qualifier("geminiRestTemplate") + + @Autowired + private RestTemplate restTemplate; + + @Value("${gemini.api.url}") + private String apiUrl; + + @Value("${gemini.api.key}") + private String geminiApiKey; + + /** + * Gemini API에 프롬프트를 전송하고 응답을 받아옵니다. + * @param prompt Gemini API에 전송할 프롬프트 문자열 + * @return Gemini API로부터 받은 응답 메시지 + */ + public String getContents(String prompt) { + + + String requestUrl = apiUrl + "?key=" + geminiApiKey; + + ChatRequestDto request = new ChatRequestDto(prompt); + ChatResponseDto response = restTemplate.postForObject(requestUrl, request, ChatResponseDto.class); + + String message = response.getCandidates().get(0).getContent().getParts().get(0).getText().toString(); + + return message; + + } +} diff --git a/src/main/java/com/example/gamemate/domain/like/controller/LikeController.java b/src/main/java/com/example/gamemate/domain/like/controller/LikeController.java new file mode 100644 index 0000000..7e751b0 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/controller/LikeController.java @@ -0,0 +1,94 @@ +package com.example.gamemate.domain.like.controller; + +import com.example.gamemate.domain.like.dto.request.LikeRequestDto; +import com.example.gamemate.domain.like.dto.response.BoardLikeCountResponseDto; +import com.example.gamemate.domain.like.dto.response.BoardLikeResponseDto; +import com.example.gamemate.domain.like.dto.response.ReviewLikeCountResponseDto; +import com.example.gamemate.domain.like.dto.response.ReviewLikeResponseDto; +import com.example.gamemate.domain.like.enums.LikeStatus; +import com.example.gamemate.domain.like.service.LikeService; +import com.example.gamemate.global.config.auth.CustomUserDetails; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * 좋아요 기능을 처리하는 컨트롤러 클래스입니다. + * 리뷰와 게시판에 대한 좋아요 기능을 제공합니다. + */ +@RestController +@RequestMapping("/likes") +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + + /** + * 리뷰에 대한 좋아요를 처리합니다. + * + * @param reviewId 좋아요를 누를 리뷰의 ID + * @param status 좋아요 상태 + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 좋아요 처리 결과를 담은 ResponseEntity + */ + @PostMapping("/reviews/{reviewId}") + public ResponseEntity reviewLikeUp( + @PathVariable Long reviewId, + @RequestBody LikeRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + ReviewLikeResponseDto responseDto = likeService.reviewLikeUp(reviewId, requestDto.getStatus(), customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + /** + * 게시글에 대한 좋아요를 처리합니다. + * + * @param boardId 좋아요를 누를 게시글의 ID + * @param status 좋아요 상태 (1: 좋아요, 0: 좋아요 취소, -1:싫어요) + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 좋아요 처리 결과를 담은 ResponseEntity + */ + @PostMapping("/boards/{boardId}") + public ResponseEntity boardLikeUp( + @PathVariable Long boardId, + @RequestBody LikeRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + BoardLikeResponseDto responseDto = likeService.boardLikeUp(boardId, requestDto.getStatus(), customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + /** + * 특정 리뷰의 좋아요 수를 조회합니다. + * + * @param reviewId 조회할 리뷰의 ID + * @return 리뷰의 좋아요 수를 담은 ResponseEntity + */ + @GetMapping("/reviews/{reviewId}") + public ResponseEntity reviewLikeCount( + @PathVariable Long reviewId) { + + Long likeCount = likeService.getReivewLikeCount(reviewId); + ReviewLikeCountResponseDto responseDto = new ReviewLikeCountResponseDto(reviewId, likeCount); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + /** + * 특정 게시글의 좋아요 수를 조회합니다. + * + * @param boardId 조회할 게시글의 ID + * @return 게시글의 좋아요 수를 담은 ResponseEntity + */ + @GetMapping("/boards/{boardId}") + public ResponseEntity boardLikeCount( + @PathVariable Long boardId) { + + Long likeCount = likeService.getBoardLikeCount(boardId); + BoardLikeCountResponseDto responseDto = new BoardLikeCountResponseDto(boardId, likeCount); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/gamemate/domain/like/dto/request/LikeRequestDto.java b/src/main/java/com/example/gamemate/domain/like/dto/request/LikeRequestDto.java new file mode 100644 index 0000000..d7f34a7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/dto/request/LikeRequestDto.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.like.dto.request; + +import com.example.gamemate.domain.like.enums.LikeStatus; + +public class LikeRequestDto { + private final LikeStatus status; + + public LikeRequestDto(LikeStatus status) { + this.status = status; + } + + public LikeStatus getStatus() { + return status; + } +} + diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeCountResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeCountResponseDto.java new file mode 100644 index 0000000..1079ca9 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeCountResponseDto.java @@ -0,0 +1,15 @@ +package com.example.gamemate.domain.like.dto.response; + +import lombok.Getter; + +@Getter +public class BoardLikeCountResponseDto { + private Long boardId; + private Long likeCount; + + public BoardLikeCountResponseDto(Long boardId, Long likeCount){ + this.boardId = boardId; + this.likeCount = likeCount; + } + +} diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeResponseDto.java new file mode 100644 index 0000000..13dea47 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/dto/response/BoardLikeResponseDto.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.like.dto.response; + +import com.example.gamemate.domain.like.entity.BoardLike; +import com.example.gamemate.domain.like.enums.LikeStatus; +import lombok.Getter; + +@Getter +public class BoardLikeResponseDto { + private Long boardId; + private Long userId; + private LikeStatus status; + + + public BoardLikeResponseDto(BoardLike boardLike){ + this.boardId = boardLike.getBoard().getId(); + this.status = boardLike.getStatus(); + this.userId = boardLike.getUser().getId(); + } + +} diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeCountResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeCountResponseDto.java new file mode 100644 index 0000000..b2611e7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeCountResponseDto.java @@ -0,0 +1,15 @@ +package com.example.gamemate.domain.like.dto.response; + +import lombok.Getter; + +@Getter +public class ReviewLikeCountResponseDto { + private Long reviewId; + private Long likeCount; + + public ReviewLikeCountResponseDto(Long reviewId, Long likeCount){ + this.reviewId = reviewId; + this.likeCount = likeCount; + } + +} diff --git a/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeResponseDto.java b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeResponseDto.java new file mode 100644 index 0000000..366d449 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/dto/response/ReviewLikeResponseDto.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.like.dto.response; + +import com.example.gamemate.domain.like.entity.ReviewLike; +import com.example.gamemate.domain.like.enums.LikeStatus; +import lombok.Getter; + +@Getter +public class ReviewLikeResponseDto { + private Long reviewId; + private Long userId; + private LikeStatus status; + + + public ReviewLikeResponseDto(ReviewLike reviewLike){ + this.reviewId = reviewLike.getReview().getId(); + this.status = reviewLike.getStatus(); + this.userId = reviewLike.getUser().getId(); + } + +} diff --git a/src/main/java/com/example/gamemate/domain/like/entity/BoardLike.java b/src/main/java/com/example/gamemate/domain/like/entity/BoardLike.java new file mode 100644 index 0000000..58ad698 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/entity/BoardLike.java @@ -0,0 +1,40 @@ +package com.example.gamemate.domain.like.entity; + +import com.example.gamemate.domain.board.entity.Board; +import com.example.gamemate.domain.like.enums.LikeStatus; +import com.example.gamemate.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BoardLike { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private LikeStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + + public BoardLike(LikeStatus status, User user, Board board) { + this.status = status; + this.user = user; + this.board = board; + } + + // 좋아요 상태 변경을 위한 메서드 + public void changeStatus(LikeStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/example/gamemate/domain/like/entity/ReviewLike.java b/src/main/java/com/example/gamemate/domain/like/entity/ReviewLike.java new file mode 100644 index 0000000..68902fa --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/entity/ReviewLike.java @@ -0,0 +1,40 @@ +package com.example.gamemate.domain.like.entity; + +import com.example.gamemate.domain.like.enums.LikeStatus; +import com.example.gamemate.domain.review.entity.Review; +import com.example.gamemate.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReviewLike { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private LikeStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id") + private Review review; + + public ReviewLike(LikeStatus status, User user, Review review) { + this.status = status; + this.user = user; + this.review = review; + } + + // 좋아요 상태 변경을 위한 메서드 + public void changeStatus(LikeStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/example/gamemate/domain/like/enums/LikeStatus.java b/src/main/java/com/example/gamemate/domain/like/enums/LikeStatus.java new file mode 100644 index 0000000..8811fb8 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/enums/LikeStatus.java @@ -0,0 +1,34 @@ +package com.example.gamemate.domain.like.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum LikeStatus { + LIKE("like"), + DISLIKE("disLike"), + NEUTRAL("neutral"); + + private final String value; + + LikeStatus(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static LikeStatus fromValue(String value) { + if (value == null) { + return null; + } + for (LikeStatus status : values()) { + if (status.value.equalsIgnoreCase(value)) { + return status; + } + } + throw new IllegalArgumentException("Invalid LikeStatus value: " + value); + } +} diff --git a/src/main/java/com/example/gamemate/domain/like/repository/BoardLikeRepository.java b/src/main/java/com/example/gamemate/domain/like/repository/BoardLikeRepository.java new file mode 100644 index 0000000..0cdf292 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/repository/BoardLikeRepository.java @@ -0,0 +1,19 @@ +package com.example.gamemate.domain.like.repository; + +import com.example.gamemate.domain.like.entity.BoardLike; +import com.example.gamemate.domain.like.enums.LikeStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface BoardLikeRepository extends JpaRepository { + + @Query("SELECT bl FROM BoardLike bl WHERE bl.board.id = :id AND bl.user.id = :userId") + Optional findByBoardIdAndUserId(@Param("id") Long boardId, @Param("userId") Long userId); + + Long countByBoardIdAndStatus(Long boardId, LikeStatus status); + + +} diff --git a/src/main/java/com/example/gamemate/domain/like/repository/ReviewLikeRepository.java b/src/main/java/com/example/gamemate/domain/like/repository/ReviewLikeRepository.java new file mode 100644 index 0000000..c4fbc82 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/repository/ReviewLikeRepository.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.like.repository; + +import com.example.gamemate.domain.like.entity.ReviewLike; +import com.example.gamemate.domain.like.enums.LikeStatus; +import org.springframework.data.jpa.repository.JpaRepository; + + +import java.util.Optional; + +public interface ReviewLikeRepository extends JpaRepository { + + Optional findByReviewIdAndUserId(Long reviewId, Long userId); + + Long countByReviewIdAndStatus(Long reviewId, LikeStatus status); + +} diff --git a/src/main/java/com/example/gamemate/domain/like/service/LikeService.java b/src/main/java/com/example/gamemate/domain/like/service/LikeService.java new file mode 100644 index 0000000..b158d3f --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/like/service/LikeService.java @@ -0,0 +1,95 @@ +package com.example.gamemate.domain.like.service; + +import com.example.gamemate.domain.board.repository.BoardRepository; +import com.example.gamemate.domain.like.dto.response.BoardLikeResponseDto; +import com.example.gamemate.domain.like.dto.response.ReviewLikeResponseDto; +import com.example.gamemate.domain.like.entity.BoardLike; +import com.example.gamemate.domain.like.entity.ReviewLike; +import com.example.gamemate.domain.like.enums.LikeStatus; +import com.example.gamemate.domain.like.repository.BoardLikeRepository; +import com.example.gamemate.domain.like.repository.ReviewLikeRepository; +import com.example.gamemate.domain.notification.enums.NotificationType; +import com.example.gamemate.domain.notification.service.NotificationService; +import com.example.gamemate.domain.review.repository.ReviewRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.eventListener.event.BoardLikeCreatedEvent; +import com.example.gamemate.global.eventListener.event.ReviewLikeCreatedEvent; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikeService { + private final ReviewLikeRepository reviewLikeRepository; + private final UserRepository userRepository; + private final ReviewRepository reviewRepository; + private final BoardLikeRepository boardLikeRepository; + private final BoardRepository boardRepository; + private final ApplicationEventPublisher publisher; + + //리뷰 좋아요 생성 취소 수정 + @Transactional + public ReviewLikeResponseDto reviewLikeUp(Long reviewId, LikeStatus status, User loginUser) { + + ReviewLike reviewLike = reviewLikeRepository.findByReviewIdAndUserId(reviewId, loginUser.getId()). + orElse(new ReviewLike( + status, + userRepository.findById(loginUser.getId()) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)), + reviewRepository.findById(reviewId) + .orElseThrow(() -> new ApiException(ErrorCode.REVIEW_NOT_FOUND)) + )); + + if (reviewLike.getId() == null) { + reviewLikeRepository.save(reviewLike); + publisher.publishEvent(new ReviewLikeCreatedEvent(this, reviewLike)); + } else { + reviewLike.changeStatus(status); + } + + return new ReviewLikeResponseDto(reviewLike); + } + + //게시물 좋아요 생성 취소 수정 + @Transactional + public BoardLikeResponseDto boardLikeUp(Long boardId, LikeStatus status, User loginUser) { + + BoardLike boardLike = boardLikeRepository.findByBoardIdAndUserId(boardId, loginUser.getId()). + orElse(new BoardLike( + status, + userRepository.findById(loginUser.getId()) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)), + boardRepository.findById(boardId) + .orElseThrow(() -> new ApiException(ErrorCode.BOARD_NOT_FOUND)) + )); + + if (boardLike.getId() == null) { + boardLikeRepository.save(boardLike); + publisher.publishEvent(new BoardLikeCreatedEvent(this, boardLike)); + } else { + boardLike.changeStatus(status); + } + + return new BoardLikeResponseDto(boardLike); + } + + public Long getBoardLikeCount(Long boardId) { + + boardRepository.findById(boardId) + .orElseThrow(() -> new ApiException(ErrorCode.BOARD_NOT_FOUND)); + return boardLikeRepository.countByBoardIdAndStatus(boardId, LikeStatus.LIKE); + } + + public Long getReivewLikeCount(Long reviewId) { + + reviewRepository.findById(reviewId) + .orElseThrow(() -> new ApiException(ErrorCode.REVIEW_NOT_FOUND)); + + return reviewLikeRepository.countByReviewIdAndStatus(reviewId, LikeStatus.LIKE); + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/controller/MatchController.java b/src/main/java/com/example/gamemate/domain/match/controller/MatchController.java new file mode 100644 index 0000000..fda01d6 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/controller/MatchController.java @@ -0,0 +1,206 @@ +package com.example.gamemate.domain.match.controller; + +import com.example.gamemate.domain.match.dto.*; +import com.example.gamemate.domain.match.service.MatchService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 매칭 기능을 처리하는 컨트롤러 클래스입니다. + * 사용자 간의 매칭 기능을 제공합니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/matches") +public class MatchController { + private final MatchService matchService; + + /** + * 사용자 간의 매칭 요청을 생성합니다. + * + * @param dto 매칭을 원하는 상대방 ID, 상대방에게 보낼 메세지를 포함합니다. + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 매칭 요청 처리 결과를 담은 ResponseEntity + */ + @PostMapping + public ResponseEntity createMatch( + @Valid @RequestBody MatchCreateRequestDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + MatchResponseDto matchResponseDto = matchService.createMatch(dto, customUserDetails.getUser()); + return new ResponseEntity<>(matchResponseDto, HttpStatus.CREATED); + } + + /** + * 받은 매칭 요청의 수락/거절을 처리합니다. + * + * @param id 수락/거절할 매칭 요청 ID + * @param dto status (ACCEPTED 수락 / REJECTED 거절) + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 204 NO_CONTENT 성공했지만 반환값이 없음 + */ + @PatchMapping("/{id}") + public ResponseEntity updateMatch( + @PathVariable Long id, + @Valid @RequestBody MatchUpdateRequestDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + matchService.updateMatch(id, dto, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + + /** + * 사용자가 받은 매칭 요청을 조회합니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 사용자의 받은 매칭 요청 목록을 담은 ResponseEntity + */ + @GetMapping("/received-matches") + public ResponseEntity> findAllReceivedMatch( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + List matchResponseDtoList = matchService.findAllReceivedMatch(customUserDetails.getUser()); + return new ResponseEntity<>(matchResponseDtoList, HttpStatus.OK); + } + + /** + * 사용자가 보낸 매칭 요청을 조회합니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 사용자가 보낸 매칭 요청 목록을 담은 ResponseEntity + */ + @GetMapping("/sent-matches") + public ResponseEntity> findAllSentMatch( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + List matchResponseDtoList = matchService.findAllSentMatch(customUserDetails.getUser()); + return new ResponseEntity<>(matchResponseDtoList, HttpStatus.OK); + } + + /** + * 사용자가 보낸 매칭 요청을 취소합니다. + * + * @param id 취소할 매칭 요청 ID + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 204 NO_CONTENT 성공했지만 반환값 없음 + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteMatch( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + matchService.deleteMatch(id, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 매칭을 위한 정보를 입력합니다. + * + * @param dto 매칭을 위해 자신의 정보를 입력합니다. + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 사용자의 정보가 처리된 ResponseEntity + */ + @PostMapping("/my-info") + public ResponseEntity createMyInfo( + @Valid @RequestBody MatchInfoCreateRequestDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + MatchInfoResponseDto matchInfoResponseDto = matchService.createMyInfo(dto, customUserDetails.getUser()); + return new ResponseEntity<>(matchInfoResponseDto, HttpStatus.CREATED); + } + + /** + * 매칭을 위해 입력한 내 정보를 확인합니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 내 정보를 담은 ResponseEntity + */ + @GetMapping("/my-info") + public ResponseEntity findMyInfo( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + MatchInfoResponseDto matchInfoResponseDto = matchService.findMyInfo(customUserDetails.getUser()); + return new ResponseEntity<>(matchInfoResponseDto, HttpStatus.OK); + } + + /** + * 매칭 상대방의 입력한 정보를 확인합니다. + * + * @param id 확인할 매칭 요청 ID + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 매칭 요청 ID의 상대방이 입력한 정보를 담은 ResponseEntity + */ + @GetMapping("/{id}/opponent-info") + public ResponseEntity findOpponentInfo( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + MatchInfoResponseDto matchInfoResponseDto = matchService.findOpponentInfo(id, customUserDetails.getUser()); + return new ResponseEntity<>(matchInfoResponseDto, HttpStatus.OK); + } + + /** + * 입력한 내 정보를 수정합니다. + * + * @param dto 수정할 정보를 입력합니다. + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 204 NO_CONTENT 성공했지만 반환값 없음 + */ + @PutMapping("/my-info") + public ResponseEntity updateMyInfo( + @Valid @RequestBody MatchInfoUpdateRequestDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + matchService.updateMyInfo(dto, customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 내 정보 삭제, 내 정보 삭제시 더이상 매칭에서 검색되지 않습니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 204 NO_CONTENT 성공했지만 반환값 없음 + */ + @DeleteMapping("/my-info") + public ResponseEntity deleteMyInfo( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + matchService.deleteMyInfo(customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 매칭 추천 받기 + * + * @param dto 원하는 매칭 조건을 설정합니다. + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 원하는 매칭 조건을 토대로 매칭 로직을 통해 가장 점수가 높은 5명을 추천해줍니다. + */ + @PostMapping("/recommendations") + public ResponseEntity> findRecommendation( + @Valid @RequestBody MatchSearchConditionDto dto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + List recommendationList = matchService.findRecommendation(dto, customUserDetails.getUser()); + return new ResponseEntity<>(recommendationList, HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchCreateRequestDto.java new file mode 100644 index 0000000..d2ddfa3 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchCreateRequestDto.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.match.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class MatchCreateRequestDto { + @NotNull + private Long userId; + + @NotNull + @Size(max = 100, message = "메시지는 100자를 초과할 수 없습니다.") + private String message; + + public MatchCreateRequestDto(Long userId, String message) { + this.userId = userId; + this.message = message; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoCreateRequestDto.java new file mode 100644 index 0000000..ae5a816 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoCreateRequestDto.java @@ -0,0 +1,62 @@ +package com.example.gamemate.domain.match.dto; + +import com.example.gamemate.domain.match.enums.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +import java.util.Set; + +@Getter +public class MatchInfoCreateRequestDto { + @NotNull(message = "성별은 필수 입력값입니다.") + private Gender gender; + + @NotNull(message = "라인은 필수 입력값입니다.") + @Size(min = 1, max = 2, message = "라인은 1-2개 선택 가능합니다.") + private Set lanes; + + @NotNull(message = "목적은 필수 입력값입니다.") + @Size(min = 1, max = 3, message = "목적은 1-3개 선택 가능합니다.") + private Set purposes; + + @NotNull(message = "게임 랭크는 필수 입력값입니다.") + private GameRank gameRank; + + @NotNull(message = "플레이 시간대는 필수 입력값입니다.") + @Size(min = 1, max = 2, message = "플레이 시간대는 1-2개 선택 가능합니다.") + private Set playTimeRanges; + + @NotNull(message = "스킬 레벨은 필수 입력값입니다.") + @Min(value = 1, message = "스킬 레벨은 1 이상이어야 합니다.") + @Max(value = 5, message = "스킬 레벨은 5 이하여야 합니다.") + private Integer skillLevel; + + @NotNull(message = "마이크 사용 여부는 필수 입력값입니다.") + private Boolean micUsage; + + @Size(max = 200, message = "메시지는 200자를 초과할 수 없습니다.") + private String message; + + public MatchInfoCreateRequestDto( + Gender gender, + Set lanes, + Set purposes, + GameRank gameRank, + Set playTimeRanges, + Integer skillLevel, + Boolean micUsage, + String message + ) { + this.gender = gender; + this.lanes = lanes; + this.purposes = purposes; + this.gameRank = gameRank; + this.playTimeRanges = playTimeRanges; + this.skillLevel = skillLevel; + this.micUsage = micUsage; + this.message = message; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoResponseDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoResponseDto.java new file mode 100644 index 0000000..0cafd6b --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoResponseDto.java @@ -0,0 +1,60 @@ +package com.example.gamemate.domain.match.dto; + +import com.example.gamemate.domain.match.entity.MatchUserInfo; +import com.example.gamemate.domain.match.enums.*; +import lombok.Getter; + +import java.util.Set; + +@Getter +public class MatchInfoResponseDto { + private Long matchUserInfoId; + private String nickname; + private Gender gender; + private Set lanes; + private Set purposes; + private GameRank gameRank; + private Set playTimeRanges; + private Integer skillLevel; + private Boolean micUsage; + private String message; + + public MatchInfoResponseDto( + Long matchUserInfoId, + String nickname, + Gender gender, + Set lanes, + Set purposes, + GameRank gameRank, + Set playTimeRanges, + Integer skillLevel, + Boolean micUsage, + String message + ) { + this.matchUserInfoId = matchUserInfoId; + this.nickname = nickname; + this.gender = gender; + this.lanes = lanes; + this.purposes = purposes; + this.gameRank = gameRank; + this.playTimeRanges = playTimeRanges; + this.skillLevel = skillLevel; + this.micUsage = micUsage; + this.message = message; + } + + public static MatchInfoResponseDto toDto(MatchUserInfo matchUserInfo) { + return new MatchInfoResponseDto( + matchUserInfo.getId(), + matchUserInfo.getUser().getNickname(), + matchUserInfo.getGender(), + matchUserInfo.getLanes(), + matchUserInfo.getPurposes(), + matchUserInfo.getGameRank(), + matchUserInfo.getPlayTimeRanges(), + matchUserInfo.getSkillLevel(), + matchUserInfo.getMicUsage(), + matchUserInfo.getMessage() + ); + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoUpdateRequestDto.java new file mode 100644 index 0000000..51ad254 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchInfoUpdateRequestDto.java @@ -0,0 +1,62 @@ +package com.example.gamemate.domain.match.dto; + +import com.example.gamemate.domain.match.enums.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +import java.util.Set; + +@Getter +public class MatchInfoUpdateRequestDto { + @NotNull(message = "성별은 필수 입력값입니다.") + private Gender gender; + + @NotNull(message = "라인은 필수 입력값입니다.") + @Size(min = 1, max = 2, message = "라인은 1-2개 선택 가능합니다.") + private Set lanes; + + @NotNull(message = "목적은 필수 입력값입니다.") + @Size(min = 1, max = 3, message = "목적은 1-3개 선택 가능합니다.") + private Set purposes; + + @NotNull(message = "게임 랭크는 필수 입력값입니다.") + private GameRank gameRank; + + @NotNull(message = "플레이 시간대는 필수 입력값입니다.") + @Size(min = 1, max = 2, message = "플레이 시간대는 1-2개 선택 가능합니다.") + private Set playTimeRanges; + + @NotNull(message = "스킬 레벨은 필수 입력값입니다.") + @Min(value = 1, message = "스킬 레벨은 1 이상이어야 합니다.") + @Max(value = 5, message = "스킬 레벨은 5 이하여야 합니다.") + private Integer skillLevel; + + @NotNull(message = "마이크 사용 여부는 필수 입력값입니다.") + private Boolean micUsage; + + @Size(max = 200, message = "메시지는 200자를 초과할 수 없습니다.") + private String message; + + public MatchInfoUpdateRequestDto( + Gender gender, + Set lanes, + Set purposes, + GameRank gameRank, + Set playTimeRanges, + Integer skillLevel, + Boolean micUsage, + String message + ) { + this.gender = gender; + this.lanes = lanes; + this.purposes = purposes; + this.gameRank = gameRank; + this.playTimeRanges = playTimeRanges; + this.skillLevel = skillLevel; + this.micUsage = micUsage; + this.message = message; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchResponseDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchResponseDto.java new file mode 100644 index 0000000..0b4e62e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchResponseDto.java @@ -0,0 +1,32 @@ +package com.example.gamemate.domain.match.dto; + +import com.example.gamemate.domain.match.entity.Match; +import com.example.gamemate.domain.match.enums.MatchStatus; +import lombok.Getter; + +@Getter +public class MatchResponseDto { + private Long id; + private MatchStatus status; + private String senderNickname; + private String receiverNickname; + private String message; + + public MatchResponseDto(Long id, MatchStatus status, String senderNickname, String receiverNickname, String message) { + this.id = id; + this.status = status; + this.senderNickname = senderNickname; + this.receiverNickname = receiverNickname; + this.message = message; + } + + public static MatchResponseDto toDto(Match match) { + return new MatchResponseDto( + match.getId(), + match.getStatus(), + match.getSender().getNickname(), + match.getReceiver().getNickname(), + match.getMessage() + ); + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchSearchConditionDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchSearchConditionDto.java new file mode 100644 index 0000000..a82cf3b --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchSearchConditionDto.java @@ -0,0 +1,61 @@ +package com.example.gamemate.domain.match.dto; + +import com.example.gamemate.domain.match.enums.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +import java.util.Set; + +@Getter +public class MatchSearchConditionDto { + @NotNull(message = "성별은 필수 입력값입니다.") + private Gender gender; + + @NotNull(message = "라인은 필수 입력값입니다.") + @Size(min = 1, max = 2, message = "라인은 1-2개 선택 가능합니다.") + private Set lanes; + + @NotNull(message = "목적은 필수 입력값입니다.") + @Size(min = 1, max = 3, message = "목적은 1-3개 선택 가능합니다.") + private Set purposes; + + @NotNull(message = "게임 랭크는 필수 입력값입니다.") + private GameRank gameRank; + + @NotNull(message = "플레이 시간대는 필수 입력값입니다.") + @Size(min = 1, max = 2, message = "플레이 시간대는 1-2개 선택 가능합니다.") + private Set playTimeRanges; + + @NotNull(message = "스킬 레벨은 필수 입력값입니다.") + @Min(value = 1, message = "스킬 레벨은 1 이상이어야 합니다.") + @Max(value = 5, message = "스킬 레벨은 5 이하여야 합니다.") + private Integer skillLevel; + + @NotNull(message = "마이크 사용 여부는 필수 입력값입니다.") + private Boolean micUsage; + + private Priority priority; + + public MatchSearchConditionDto( + Gender gender, + Set lanes, + Set purposes, + GameRank gameRank, + Set playTimeRanges, + Integer skillLevel, + Boolean micUsage, + Priority priority + ) { + this.gender = gender; + this.lanes = lanes; + this.purposes = purposes; + this.gameRank = gameRank; + this.playTimeRanges = playTimeRanges; + this.skillLevel = skillLevel; + this.micUsage = micUsage; + this.priority = priority; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/dto/MatchUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/match/dto/MatchUpdateRequestDto.java new file mode 100644 index 0000000..b03bd19 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/dto/MatchUpdateRequestDto.java @@ -0,0 +1,15 @@ +package com.example.gamemate.domain.match.dto; + +import com.example.gamemate.domain.match.enums.MatchStatus; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class MatchUpdateRequestDto { + @NotNull + private MatchStatus status; + + public MatchUpdateRequestDto(MatchStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/entity/Match.java b/src/main/java/com/example/gamemate/domain/match/entity/Match.java new file mode 100644 index 0000000..675629d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/entity/Match.java @@ -0,0 +1,44 @@ +package com.example.gamemate.domain.match.entity; + +import com.example.gamemate.domain.match.enums.MatchStatus; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "matches") +public class Match extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private MatchStatus status; + + @Column + private String message; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = false) + private User sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = false) + private User receiver; + + public Match() { + } + + public Match(String message, User sender, User receiver) { + this.status = MatchStatus.PENDING; + this.message = message; + this.sender = sender; + this.receiver = receiver; + } + + public void updateStatus(MatchStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/entity/MatchUserInfo.java b/src/main/java/com/example/gamemate/domain/match/entity/MatchUserInfo.java new file mode 100644 index 0000000..7ab5ce1 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/entity/MatchUserInfo.java @@ -0,0 +1,104 @@ +package com.example.gamemate.domain.match.entity; + +import com.example.gamemate.domain.match.enums.*; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; + +import java.util.HashSet; +import java.util.Set; + +@Getter +@Entity +public class MatchUserInfo extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private Gender gender; + + @ElementCollection + @CollectionTable(name = "user_lanes") + @Enumerated(EnumType.STRING) + private Set lanes = new HashSet<>(); + + @ElementCollection + @CollectionTable(name = "user_purposes") + @Enumerated(EnumType.STRING) + private Set purposes = new HashSet<>(); + + @ElementCollection + @CollectionTable(name = "user_play_times") + @Enumerated(EnumType.STRING) + private Set playTimeRanges = new HashSet<>(); + + @Enumerated(EnumType.STRING) + private GameRank gameRank; + + @Column + private Integer skillLevel; + + @Column + private Boolean micUsage; + + @Column + private String message; + + @OneToOne + @JoinColumn(name = "user_id") + private User user; + + @Transient // DB에 저장하지 않고 런타임에만 사용 + private int matchScore; + + public MatchUserInfo() { + } + + public MatchUserInfo( + Gender gender, + Set lanes, + Set purposes, + Set playTimeRanges, + GameRank gameRank, + Integer skillLevel, + Boolean micUsage, + String message, + User user + ) { + this.gender = gender; + this.lanes = lanes; + this.purposes = purposes; + this.playTimeRanges = playTimeRanges; + this.gameRank = gameRank; + this.skillLevel = skillLevel; + this.micUsage = micUsage; + this.message = message; + this.user = user; + } + + public void updateMatchUserInfo( + Gender gender, + Set lanes, + Set purposes, + Set playTimeRanges, + GameRank gameRank, + Integer skillLevel, + Boolean micUsage, + String message + ) { + this.gender = gender; + this.lanes = lanes; + this.purposes = purposes; + this.playTimeRanges = playTimeRanges; + this.gameRank = gameRank; + this.skillLevel = skillLevel; + this.micUsage = micUsage; + this.message = message; + } + + public void updateMatchScore(int matchScore) { + this.matchScore = matchScore; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/enums/GameRank.java b/src/main/java/com/example/gamemate/domain/match/enums/GameRank.java new file mode 100644 index 0000000..7739742 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/enums/GameRank.java @@ -0,0 +1,25 @@ +package com.example.gamemate.domain.match.enums; + +import lombok.Getter; + +@Getter +public enum GameRank { + DONT_MIND("dont_mind", "상관없음"), + IRON("iron", "아이언"), + BRONZE("bronze", "브론즈"), + SILVER("silver", "실버"), + GOLD("gold", "골드"), + PLATINUM("platinum", "플래티넘"), + DIAMOND("diamond", "다이아"), + MASTER("master", "마스터"), + GRANDMASTER("grandmaster", "그랜드마스터"), + CHALLENGER("challenger", "챌린저"); + + private final String name; + private final String koreanName; + + GameRank(String name, String koreanName) { + this.name = name; + this.koreanName = koreanName; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Gender.java b/src/main/java/com/example/gamemate/domain/match/enums/Gender.java new file mode 100644 index 0000000..176d03a --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/enums/Gender.java @@ -0,0 +1,15 @@ +package com.example.gamemate.domain.match.enums; + +import lombok.Getter; + +@Getter +public enum Gender { + MALE("male"), + FEMALE("female"); + + private final String name; + + Gender(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Lane.java b/src/main/java/com/example/gamemate/domain/match/enums/Lane.java new file mode 100644 index 0000000..9151294 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/enums/Lane.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.match.enums; + +import lombok.Getter; + +@Getter +public enum Lane { + TOP("top", "탑"), + JUNGLE("jungle", "정글"), + MID("mid","미드"), + BOTTOM_AD("bottom_ad", "원딜"), + BOTTOM_SUPPORTER("bottom_supporter", "서포터"); + + private final String name; + private final String koreanName; + + Lane(String name, String koreanName) { + this.name = name; + this.koreanName = koreanName; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/enums/MatchStatus.java b/src/main/java/com/example/gamemate/domain/match/enums/MatchStatus.java new file mode 100644 index 0000000..2cccc1f --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/enums/MatchStatus.java @@ -0,0 +1,13 @@ +package com.example.gamemate.domain.match.enums; + +public enum MatchStatus { + ACCEPTED("accepted"), + PENDING("pending"), + REJECTED("rejected"); + + private final String name; + + MatchStatus(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/enums/PlayTimeRange.java b/src/main/java/com/example/gamemate/domain/match/enums/PlayTimeRange.java new file mode 100644 index 0000000..14cab4e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/enums/PlayTimeRange.java @@ -0,0 +1,19 @@ +package com.example.gamemate.domain.match.enums; + +import lombok.Getter; + +@Getter +public enum PlayTimeRange { + ZERO_TO_SIX("zero_to_six", "0~6시"), + SIX_TO_TWELVE("six_to_twelve", "6~12시"), + TWELVE_TO_EIGHTEEN("twelve_to_eighteen", "12~18시"), + EIGHTEEN_TO_TWENTY_FOUR("eighteen_to_twenty_four", "18시~24시"); + + private final String name; + private final String koreanName; + + PlayTimeRange(String name, String koreanName) { + this.name = name; + this.koreanName = koreanName; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Priority.java b/src/main/java/com/example/gamemate/domain/match/enums/Priority.java new file mode 100644 index 0000000..35461f1 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/enums/Priority.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.match.enums; + +import lombok.Getter; + +@Getter +public enum Priority { + GAME_RANK("gameRank"), + GENDER("gender"), + LANES("lanes"), + PLAY_TIME_RANGES("playTimeRanges"), + PURPOSES("purposes"), + MIC_USAGE("micUsage"), + SKILL_LEVEL("skillLevel"); + + private final String name; + + Priority(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/enums/Purpose.java b/src/main/java/com/example/gamemate/domain/match/enums/Purpose.java new file mode 100644 index 0000000..6bc7d7c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/enums/Purpose.java @@ -0,0 +1,24 @@ +package com.example.gamemate.domain.match.enums; + +import lombok.Getter; + +@Getter +public enum Purpose { + JUST_FOR_FUN("just_for_fun", "즐겜"), + TRY_HARD("try_hard", "빡겜"), + RANK_GAME("rank_game", "랭겜"), + NORMAL_GAME("normal_game", "일반겜"), + TEAMWORK("teamwork", "팀워크"), + WANT_FRIEND("want_friend", "친구 구함"), + MENTORING("mentoring", "멘토/멘티 구함"), + DUO_PLAY("duo_play", "듀오할 사람 구함"), + BEGINNER_FRIENDLY("beginner_friendly","뉴비 구함"); + + private final String name; + private final String koreanName; + + Purpose(String name, String koreanName) { + this.name = name; + this.koreanName = koreanName; + } +} diff --git a/src/main/java/com/example/gamemate/domain/match/repository/MatchRepository.java b/src/main/java/com/example/gamemate/domain/match/repository/MatchRepository.java new file mode 100644 index 0000000..cc09ca7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/repository/MatchRepository.java @@ -0,0 +1,18 @@ +package com.example.gamemate.domain.match.repository; + +import com.example.gamemate.domain.match.entity.Match; +import com.example.gamemate.domain.match.enums.MatchStatus; +import com.example.gamemate.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MatchRepository extends JpaRepository { + + Boolean existsBySenderAndReceiverAndStatus(User sender, User receiver, MatchStatus status); + List findAllByReceiverId(Long receiverId); + List findAllBySenderId(Long senderId); + +} diff --git a/src/main/java/com/example/gamemate/domain/match/repository/MatchUserInfoRepository.java b/src/main/java/com/example/gamemate/domain/match/repository/MatchUserInfoRepository.java new file mode 100644 index 0000000..13aceff --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/repository/MatchUserInfoRepository.java @@ -0,0 +1,36 @@ +package com.example.gamemate.domain.match.repository; + +import com.example.gamemate.domain.match.entity.MatchUserInfo; +import com.example.gamemate.domain.match.enums.Gender; +import com.example.gamemate.domain.match.enums.PlayTimeRange; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.UserStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface MatchUserInfoRepository extends JpaRepository { + Optional findByUser(User user); + Boolean existsByUser(User user); + + @Query("SELECT m FROM MatchUserInfo m " + + "WHERE m.gender = :gender " + + "AND EXISTS (SELECT pt FROM m.playTimeRanges pt WHERE pt IN :playTimeRanges) " + + "AND m.user.id <> :userId " + + "AND m.user.modifiedAt >= :sevenDaysAgo " + + "AND m.user.userStatus = :userStatus") + List findByGenderAndPlayTimeRanges( + @Param("gender") Gender gender, + @Param("playTimeRanges") Set playTimeRanges, + @Param("userId") Long userId, + @Param("sevenDaysAgo") LocalDateTime sevenDaysAgo, + @Param("userStatus") UserStatus userStatus + ); +} diff --git a/src/main/java/com/example/gamemate/domain/match/service/MatchService.java b/src/main/java/com/example/gamemate/domain/match/service/MatchService.java new file mode 100644 index 0000000..cee26bf --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/match/service/MatchService.java @@ -0,0 +1,448 @@ +package com.example.gamemate.domain.match.service; + +import com.example.gamemate.domain.match.dto.*; +import com.example.gamemate.domain.match.entity.Match; +import com.example.gamemate.domain.match.entity.MatchUserInfo; +import com.example.gamemate.domain.match.enums.GameRank; +import com.example.gamemate.domain.match.enums.MatchStatus; +import com.example.gamemate.domain.match.enums.Priority; +import com.example.gamemate.domain.match.repository.MatchRepository; +import com.example.gamemate.domain.match.repository.MatchUserInfoRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.UserStatus; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.eventListener.event.MatchAcceptedEvent; +import com.example.gamemate.global.eventListener.event.MatchCreatedEvent; +import com.example.gamemate.global.eventListener.event.MatchRejectedEvent; +import com.example.gamemate.global.exception.ApiException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import static com.example.gamemate.domain.match.enums.Priority.*; + +/** + * 매칭 기능을 처리하는 서비스 클래스입니다. + */ +@Service +@RequiredArgsConstructor +public class MatchService { + + private final UserRepository userRepository; + private final MatchRepository matchRepository; + private final MatchUserInfoRepository matchUserInfoRepository; + private final ApplicationEventPublisher publisher; + + /** + * 사용자 간의 매칭 요청을 생성합니다. + * @param dto 매칭을 원하는 상대방 ID, 상대방에게 보낼 메세지를 포함합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 매칭 요청 처리 결과를 담은 MatchResponseDto + */ + @Transactional + public MatchResponseDto createMatch(MatchCreateRequestDto dto, User loginUser) { + + User receiver = userRepository.findById(dto.getUserId()) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if (!matchUserInfoRepository.existsByUser(receiver)) { + throw new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND); + } // 받는 사람의 매칭 유저 정보가 없을때 예외처리 + + if (!matchUserInfoRepository.existsByUser(loginUser)) { + throw new ApiException(ErrorCode.MATCH_USER_INFO_NOT_WRITTEN); + } // 로그인 한 유저의 매칭 유저 정보가 없을때 예외처리 + + if (receiver.getUserStatus() == UserStatus.WITHDRAW) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } // 받는 사람의 유저 상태가 탈퇴 상태일때 예외처리 + + if (matchRepository.existsBySenderAndReceiverAndStatus(loginUser, receiver, MatchStatus.PENDING)) { + throw new ApiException(ErrorCode.IS_ALREADY_PENDING); + } // 이미 보낸 요청이 있을때 예외처리 + + Match match = new Match(dto.getMessage(), loginUser, receiver); + Match savedMatch = matchRepository.save(match); + publisher.publishEvent(new MatchCreatedEvent(this, savedMatch)); + + return MatchResponseDto.toDto(match); + } + + /** + * 받은 매칭 요청의 수락/거절을 처리합니다. + * @param id 수락/거절할 매칭 요청 ID + * @param dto status (ACCEPTED 수락 / REJECTED 거절) + * @param loginUser 현재 인증된 사용자 정보 + */ + @Transactional + public void updateMatch(Long id, MatchUpdateRequestDto dto, User loginUser) { + + Match findMatch = matchRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.MATCH_NOT_FOUND)); + + if (findMatch.getStatus() != MatchStatus.PENDING) { + throw new ApiException(ErrorCode.IS_ALREADY_PROCESSED); + } // 매칭의 상태가 보류중이 아닐때 예외처리 + + if (!Objects.equals(loginUser.getId(), findMatch.getReceiver().getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } // 로그인한 유저가 매칭의 받는 사람이 아닐때 예외처리 + + if (dto.getStatus() == MatchStatus.ACCEPTED) { + publisher.publishEvent(new MatchAcceptedEvent(this, findMatch)); + } // 매칭 보낸 사람에게 매칭이 수락되었다는 알림 전송 + + if (dto.getStatus() == MatchStatus.REJECTED) { + publisher.publishEvent(new MatchRejectedEvent(this, findMatch)); + } // 매칭 보낸 사람에게 매칭이 거절되었다는 알림 전송 + + findMatch.updateStatus(dto.getStatus()); + } + + /** + * 사용자가 받은 매칭 요청을 조회합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 사용자의 받은 매칭 요청 목록을 담은 List + */ + public List findAllReceivedMatch(User loginUser) { + + List matchList = matchRepository.findAllByReceiverId(loginUser.getId()); + + return matchList.stream() + .map(MatchResponseDto::toDto) + .toList(); + } + + /** + * 사용자가 보낸 매칭 요청을 조회합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 사용자가 보낸 매칭 요청 목록을 담은 List + */ + public List findAllSentMatch(User loginUser) { + + List matchList = matchRepository.findAllBySenderId(loginUser.getId()); + + return matchList.stream() + .map(MatchResponseDto::toDto) + .toList(); + } + + /** + * 사용자가 보낸 매칭 요청을 취소합니다. + * @param id 취소할 매칭 요청 ID + * @param loginUser 현재 인증된 사용자 정보 + */ + @Transactional + public void deleteMatch(Long id, User loginUser) { + + Match findMatch = matchRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.MATCH_NOT_FOUND)); + + if (!Objects.equals(findMatch.getSender().getId(), loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } // 로그인한 유저가 매칭의 보낸사람이 아닐때 예외처리 + + matchRepository.delete(findMatch); + } + + /** + * 매칭을 위한 정보를 입력합니다. + * @param dto 매칭을 위해 자신의 정보를 입력합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 사용자의 정보가 처리된 MatchInfoResponseDto + */ + @Transactional + public MatchInfoResponseDto createMyInfo(MatchInfoCreateRequestDto dto, User loginUser) { + + MatchUserInfo matchUserInfo = new MatchUserInfo( + dto.getGender(), + dto.getLanes(), + dto.getPurposes(), + dto.getPlayTimeRanges(), + dto.getGameRank(), + dto.getSkillLevel(), + dto.getMicUsage(), + dto.getMessage(), + loginUser + ); + + matchUserInfoRepository.save(matchUserInfo); + + return MatchInfoResponseDto.toDto(matchUserInfo); + } + + /** + * 매칭을 위해 입력한 내 정보를 확인합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 내 정보를 담은 MatchInfoResponseDto + */ + public MatchInfoResponseDto findMyInfo(User loginUser) { + MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(loginUser) + .orElseThrow(() -> new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND)); + + return MatchInfoResponseDto.toDto(matchUserInfo); + } + + /** + * 매칭 상대방의 입력한 정보를 확인합니다. + * @param id 확인할 매칭 요청 ID + * @param loginUser 현재 인증된 사용자 정보 + * @return 매칭 요청 ID의 상대방이 입력한 정보를 담은 MatchInfoResponseDto + */ + public MatchInfoResponseDto findOpponentInfo(Long id, User loginUser) { + Match findMatch = matchRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.MATCH_NOT_FOUND)); + + if (!Objects.equals(findMatch.getReceiver().getId(), loginUser.getId()) + && !Objects.equals(findMatch.getSender().getId(), loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } // 매칭의 상대방을 검색할때, 로그인한 유저가 검색할 매칭과 연관이 없을때 예외처리 + + return (Objects.equals(findMatch.getReceiver().getId(), loginUser.getId())) + ? getMatchInfoResponseDto(findMatch.getSender()) + : getMatchInfoResponseDto(findMatch.getReceiver()); + } + + /** + * 입력한 내 정보를 수정합니다. + * @param dto 수정할 정보를 입력합니다. + * @param loginUser 현재 인증된 사용자 정보 + */ + @Transactional + public void updateMyInfo(MatchInfoUpdateRequestDto dto, User loginUser) { + MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(loginUser) + .orElseThrow(() -> new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND)); + + matchUserInfo.updateMatchUserInfo( + dto.getGender(), + dto.getLanes(), + dto.getPurposes(), + dto.getPlayTimeRanges(), + dto.getGameRank(), + dto.getSkillLevel(), + dto.getMicUsage(), + dto.getMessage() + ); + } + + /** + * 내 정보 삭제, 내 정보 삭제시 더이상 매칭에서 검색되지 않습니다. + * @param loginUser 현재 인증된 사용자 정보 + */ + @Transactional + public void deleteMyInfo(User loginUser) { + MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(loginUser) + .orElseThrow(() -> new ApiException(ErrorCode.MATCH_USER_INFO_NOT_FOUND)); + + matchUserInfoRepository.delete(matchUserInfo); + } + + /** + * 사용자간의 연결을 위한 매칭 로직입니다. + * @param dto MatchSearchConditionDto 검색할 상대방의 조건을 입력합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 입력한 조건과 매칭로직을 통해 점수를 매겨 상위 5명의 정보를 보여줍니다. + */ + public List findRecommendation(MatchSearchConditionDto dto, User loginUser) { + // 1. 성별과 플레이 시간대 및 최근 로그인날짜가 7일이내, 유저상태가 ACTIVE 인 필터링된 사용자 정보 조회 + LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); + + List filteredUsers = matchUserInfoRepository.findByGenderAndPlayTimeRanges( + dto.getGender(), + dto.getPlayTimeRanges(), + loginUser.getId(), + sevenDaysAgo, + UserStatus.ACTIVE + ); + + // 2. 매칭 점수 계산 및 저장 + for (MatchUserInfo matchUserInfo : filteredUsers) { + int score = calculateMatchScore(dto, matchUserInfo); + matchUserInfo.updateMatchScore(score); + } + + // 3. 매칭 점수 내림차순으로 정렬 + filteredUsers.sort((u1, u2) -> Integer.compare(u2.getMatchScore(), u1.getMatchScore())); + + + // 4. 동점자 처리 (동점자끼리 랜덤 섞기) + List resultList = handleTies(filteredUsers); + + // 5. 상위 5명 추출 및 DTO 변환 + return resultList.stream() + .limit(5) + .map(MatchInfoResponseDto::toDto) + .collect(Collectors.toList()); + } + + + /** + * 매칭의 점수 계산 로직입니다. + * @param condition 사용자가 입력한 원하는 상대의 조건입니다. + * @param userInfo 매칭에 추천될 사람들의 정보입니다. + * @return 점수계산 로직을 통해 나온 점수 + */ + private int calculateMatchScore(MatchSearchConditionDto condition, MatchUserInfo userInfo) { + int score = 0; + int normalScorePerMatch = 5; // 매칭되는 항목당 점수 + int priorityWeight = 2; // 우선순위 가중치 + + Priority priority = condition.getPriority(); + + // 우선순위 항목 점수 계산 및 가중치 적용 + if (priority != null) { + switch (priority) { + case LANES: + int matchedLanes = (int) condition.getLanes().stream() + .filter(userInfo.getLanes()::contains) + .count(); + score += matchedLanes * normalScorePerMatch * priorityWeight; + break; + case PURPOSES: + int matchedPurposes = (int) condition.getPurposes().stream() + .filter(userInfo.getPurposes()::contains) + .count(); + score += matchedPurposes * normalScorePerMatch * priorityWeight; + break; + case PLAY_TIME_RANGES: + int matchedPlayTimeRanges = (int) condition.getPlayTimeRanges().stream() + .filter(userInfo.getPlayTimeRanges()::contains) + .count(); + score += matchedPlayTimeRanges * normalScorePerMatch * priorityWeight; + break; + case GAME_RANK: + if (condition.getGameRank().equals(userInfo.getGameRank())) { + score += normalScorePerMatch * priorityWeight * 2; + } else if (isRankSimilar(condition.getGameRank(), userInfo.getGameRank())) { + score += normalScorePerMatch * priorityWeight; + } + break; + case SKILL_LEVEL: + int skillLevelDifference = Math.abs(condition.getSkillLevel() - userInfo.getSkillLevel()); + score += (normalScorePerMatch - skillLevelDifference) * priorityWeight; + break; + case MIC_USAGE: + if (condition.getMicUsage().equals(userInfo.getMicUsage())) { + score += normalScorePerMatch * priorityWeight * 2; + } + break; + } + } + + // 우선순위가 아닌 조건의 점수 계산 방식 + if (priority == null || !priority.equals(LANES)) { + int matchedLanes = (int) condition.getLanes().stream() + .filter(userInfo.getLanes()::contains) + .count(); + score += matchedLanes * normalScorePerMatch; + } + + if (priority == null || !priority.equals(PURPOSES)) { + int matchedPurposes = (int) condition.getPurposes().stream() + .filter(userInfo.getPurposes()::contains) + .count(); + score += matchedPurposes * normalScorePerMatch; + } + + if (priority == null || !priority.equals(PLAY_TIME_RANGES)) { + int matchedPlayTimeRanges = (int) condition.getPlayTimeRanges().stream() + .filter(userInfo.getPlayTimeRanges()::contains) + .count(); + score += matchedPlayTimeRanges * normalScorePerMatch; + } + + if (priority == null || !priority.equals(GAME_RANK)) { + if (condition.getGameRank().equals(userInfo.getGameRank())) { + score += normalScorePerMatch * 2; + } else if (isRankSimilar(condition.getGameRank(), userInfo.getGameRank())) { + score += normalScorePerMatch; + } + } + + if (priority == null || !priority.equals(SKILL_LEVEL)) { + int skillLevelDifference = Math.abs(condition.getSkillLevel() - userInfo.getSkillLevel()); + score += (normalScorePerMatch - skillLevelDifference); + } + + if (priority == null || !priority.equals(MIC_USAGE)) { + if (condition.getMicUsage().equals(userInfo.getMicUsage())) { + score += normalScorePerMatch * 2; + } + } + + return score; + } + + + /** + * 매칭 조건 중 랭크 조건에 비슷한 랭크인지 판단하는 메서드입니다. + * @param conditionRank 사용자가 입력한 원하는 랭크입니다. + * @param userRank 매칭로직에서 검사될 상대방들의 랭크입니다. + * @return 유사하다면 true, 아니면 false + */ + private boolean isRankSimilar(GameRank conditionRank, GameRank userRank) { + if (conditionRank == GameRank.DONT_MIND) { + return true; // "상관없음"은 모든 랭크와 유사하다고 판단 + } + + int conditionRankIndex = conditionRank.ordinal(); + int userRankIndex = userRank.ordinal(); + return Math.abs(conditionRankIndex - userRankIndex) <= 1; // 랭크 차이가 1 이하면 유사하다고 판단 + } + + + /** + * 매칭 로직을 통해 나온 동점자들을 랜덤하게 섞어서 출력합니다. + * @param sortedUsers 매칭 점수 로직을 통해 나온 사용자들입니다. + * @return 동점자들을 랜덤하게 섞어서 출력된 정보입니다. + */ + private List handleTies(List sortedUsers) { + if (sortedUsers.isEmpty()) { + return sortedUsers; + } + List resultList = new ArrayList<>(); + List tieGroup = new ArrayList<>(); // 동점자 그룹 임시 저장 + + tieGroup.add(sortedUsers.get(0)); + + for (int i = 1; i < sortedUsers.size(); i++) { + MatchUserInfo currentUser = sortedUsers.get(i); + MatchUserInfo previousUser = sortedUsers.get(i - 1); + + if (currentUser.getMatchScore() == previousUser.getMatchScore()) { + tieGroup.add(currentUser); + } else { + Collections.shuffle(tieGroup); // 동점자 그룹 섞기 + resultList.addAll(tieGroup); + tieGroup.clear(); // 다음 그룹을 위해 비우기 + tieGroup.add(currentUser); //새로운 그룹 시작 + } + } + + Collections.shuffle(tieGroup); + resultList.addAll(tieGroup); //마지막 그룹 추가 + + return resultList; + } + + /** + * 매칭의 상대방정보를 dto 로 변환합니다. + * @param user dto 로 변환할 상대방 사용자입니다. + * @return MatchInfoResponseDto + */ + private MatchInfoResponseDto getMatchInfoResponseDto(User user) { + MatchUserInfo matchUserInfo = matchUserInfoRepository.findByUser(user) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + return MatchInfoResponseDto.toDto(matchUserInfo); + } +} + diff --git a/src/main/java/com/example/gamemate/domain/notification/controller/NotificationController.java b/src/main/java/com/example/gamemate/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..721367d --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/controller/NotificationController.java @@ -0,0 +1,86 @@ +package com.example.gamemate.domain.notification.controller; + +import com.example.gamemate.domain.notification.dto.NotificationResponseDto; +import com.example.gamemate.domain.notification.service.NotificationService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +/** + * 알림을 처리하는 컨트롤러 클래스입니다. + */ +@RestController +@RequestMapping("/notifications") +@RequiredArgsConstructor +public class NotificationController { + private final NotificationService notificationService; + + /** + * 로그인한 사용자를 SSE 에 연결합니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 로그인한 사용자의 SseEmitter 를 담은 ResponseEntity + */ + @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity connect( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + SseEmitter sseEmitter = notificationService.subscribe(customUserDetails.getUser()); + return new ResponseEntity<>(sseEmitter, HttpStatus.OK); + } + + /** + * 로그인한 사용자의 전체 알림을 조회합니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 로그인한 사용자의 전체 알림을 담은 ResponseEntity + */ + @GetMapping + public ResponseEntity> findAllNotification( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + List notificationResponseDtoList = notificationService.findAllNotification(customUserDetails.getUser()); + return new ResponseEntity<>(notificationResponseDtoList, HttpStatus.OK); + } + + /** + * 단일 알림의 읽음 상태를 처리합니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @param id 읽음 상태를 처리할 알림 id + * @return 204 NO_CONTENT + */ + @PatchMapping("/{id}") + public ResponseEntity readNotification( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @PathVariable Long id + ) { + + notificationService.readNotification(customUserDetails.getUser(), id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 사용자의 모든 읽지않은 알림을 읽음 처리합니다. + * + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 204 NO_CONTENT + */ + @PatchMapping + public ResponseEntity readAllNotification( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + + notificationService.readAllNotification(customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/gamemate/domain/notification/dto/NotificationResponseDto.java b/src/main/java/com/example/gamemate/domain/notification/dto/NotificationResponseDto.java new file mode 100644 index 0000000..6e1ef0a --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/dto/NotificationResponseDto.java @@ -0,0 +1,26 @@ +package com.example.gamemate.domain.notification.dto; + +import com.example.gamemate.domain.notification.entity.Notification; +import com.example.gamemate.domain.notification.enums.NotificationType; +import lombok.Getter; + +@Getter +public class NotificationResponseDto { + private Long id; + private String content; + private NotificationType type; + private String relatedUrl; + private Long receiverId; + + public NotificationResponseDto(Long id, String content, NotificationType type, String relatedUrl, Long receiverId) { + this.id = id; + this.content = content; + this.type = type; + this.relatedUrl = relatedUrl; + this.receiverId = receiverId; + } + + public static NotificationResponseDto toDto(Notification notification) { + return new NotificationResponseDto(notification.getId(), notification.getContent(), notification.getType(), notification.getRelatedUrl(), notification.getReceiver().getId()); + } +} diff --git a/src/main/java/com/example/gamemate/domain/notification/entity/Notification.java b/src/main/java/com/example/gamemate/domain/notification/entity/Notification.java new file mode 100644 index 0000000..a090951 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/entity/Notification.java @@ -0,0 +1,47 @@ +package com.example.gamemate.domain.notification.entity; + +import com.example.gamemate.domain.notification.enums.NotificationType; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseCreatedEntity; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class Notification extends BaseCreatedEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String content; + + @Column + private String relatedUrl; + + @Enumerated(EnumType.STRING) + @Column + private NotificationType type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private User receiver; + + @Column + private boolean isRead; + + public Notification() { + } + + public Notification(String content, String relatedUrl, NotificationType type, User receiver) { + this.content = content; + this.relatedUrl = relatedUrl; + this.type = type; + this.receiver = receiver; + this.isRead = false; + } + + public void updateIsRead(boolean isRead) { + this.isRead = isRead; + } +} diff --git a/src/main/java/com/example/gamemate/domain/notification/enums/NotificationType.java b/src/main/java/com/example/gamemate/domain/notification/enums/NotificationType.java new file mode 100644 index 0000000..00d64ff --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/enums/NotificationType.java @@ -0,0 +1,21 @@ +package com.example.gamemate.domain.notification.enums; + +import lombok.Getter; + +@Getter +public enum NotificationType { + NEW_FOLLOWER("follow", "새 팔로워가 생겼습니다."), + NEW_COMMENT("comment", "새로운 댓글이 달렸습니다."), + NEW_MATCH("new_match", "새로운 매칭 요청이 왔습니다."), + MATCH_REJECTED("match_rejected", "보낸 매칭이 거절되었습니다."), + MATCH_ACCEPTED("match_accepted", "보낸 매칭이 수락되었습니다."), + NEW_LIKE("like", "새 좋아요가 달렸습니다."); + + private final String name; + private final String content; + + NotificationType(String name, String content) { + this.name = name; + this.content = content; + } +} diff --git a/src/main/java/com/example/gamemate/domain/notification/repository/EmitterRepository.java b/src/main/java/com/example/gamemate/domain/notification/repository/EmitterRepository.java new file mode 100644 index 0000000..5b5664c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/repository/EmitterRepository.java @@ -0,0 +1,48 @@ +package com.example.gamemate.domain.notification.repository; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Repository +@Slf4j +public class EmitterRepository { + // ConcurrentHashMap을 사용하여 thread-safe하게 관리 + private final Map emitters = new ConcurrentHashMap<>(); + + public SseEmitter save(Long userId, SseEmitter emitter) { + emitters.put(userId, emitter); + log.info("EmitterRepository - userId: {} 연결 저장", userId); + + // 연결 종료 시 자동 제거 + emitter.onCompletion(() -> { + log.info("EmitterRepository - userId: {} 연결 종료", userId); + this.deleteById(userId); + }); + emitter.onTimeout(() -> { + log.info("EmitterRepository - userId: {} 연결 시간 초과", userId); + this.deleteById(userId); + }); + emitter.onError((e) -> { + log.error("EmitterRepository - userId: {} 연결 에러: {}", userId, e.getMessage()); + this.deleteById(userId); + }); + + return emitter; + } + + public SseEmitter findById(Long userId) { + return emitters.get(userId); + } + + public void deleteById(Long userId) { + emitters.remove(userId); + } + + public Map findAll() { + return emitters; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/domain/notification/repository/NotificationRepository.java b/src/main/java/com/example/gamemate/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..ef47e90 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,26 @@ +package com.example.gamemate.domain.notification.repository; + +import com.example.gamemate.domain.notification.entity.Notification; +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; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface NotificationRepository extends JpaRepository { + + List findAllByReceiverId(Long receiverId); + + @Query("UPDATE Notification n " + + "SET n.isRead = true " + + "WHERE n.receiver.id = :receiverId " + + "AND n.isRead = false") + @Modifying + void updateUnreadNotificationToRead(@Param("receiverId") Long receiverId); + + Optional findTopByReceiverIdAndIsReadOrderByCreatedAtDesc(Long receiverId, boolean isRead); +} diff --git a/src/main/java/com/example/gamemate/domain/notification/service/NotificationService.java b/src/main/java/com/example/gamemate/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..91fee35 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/service/NotificationService.java @@ -0,0 +1,177 @@ +package com.example.gamemate.domain.notification.service; + +import com.example.gamemate.domain.notification.dto.NotificationResponseDto; +import com.example.gamemate.domain.notification.entity.Notification; +import com.example.gamemate.domain.notification.enums.NotificationType; +import com.example.gamemate.domain.notification.repository.EmitterRepository; +import com.example.gamemate.domain.notification.repository.NotificationRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import jakarta.annotation.PostConstruct; +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.connection.stream.StreamInfo; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.*; + +/** + * 알림을 처리하는 서비스 클래스입니다. + */ +@Service +@Slf4j +public class NotificationService { + + private static final String STREAM_KEY = "notification_stream"; + private static final String GROUP_NAME = "notification-group"; + private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; + + private final NotificationRepository notificationRepository; + private final EmitterRepository emitterRepository; + private final RedisStreamService redisStreamService; + private final RedisTemplate redisTemplate; + + public NotificationService( + NotificationRepository notificationRepository, + EmitterRepository emitterRepository, + RedisStreamService redisStreamService, + @Qualifier("notificationRedisTemplate") RedisTemplate redisTemplate) { + this.notificationRepository = notificationRepository; + this.emitterRepository = emitterRepository; + this.redisStreamService = redisStreamService; + this.redisTemplate = redisTemplate; + } + + /** + * 레디스 스트림의 스트림그룹을 생성합니다. + */ + @PostConstruct + public void init() { + try { + // 스트림이 존재하지 않으면 생성 + if (!Boolean.TRUE.equals(redisTemplate.hasKey(STREAM_KEY))) { + redisTemplate.opsForStream() + .add(StreamRecords.newRecord() + .in(STREAM_KEY) + .ofMap(Collections.singletonMap("init", "init"))); + } + + // 그룹 정보 조회 + StreamInfo.XInfoGroups groups = redisTemplate.opsForStream().groups(STREAM_KEY); + boolean groupExists = groups.stream() + .anyMatch(group -> GROUP_NAME.equals(group.groupName())); + + // 그룹이 없으면 생성 + if (!groupExists) { + redisTemplate.opsForStream().createGroup(STREAM_KEY, GROUP_NAME); + } + } catch (Exception e) { + log.error("스트림 초기화 중 오류 발생: {}", e.getMessage()); + } + } + + /** + * 알림을 생성합니다. + * @param user 알림을 받는 사용자 + * @param type 알림 타입 + * @param relatedUrl 알림과 관련된 URL + * @return Notification 생성된 알림 + */ + @Transactional + public Notification createNotification(User user, NotificationType type, String relatedUrl) { + Notification notification = new Notification(type.getContent(), relatedUrl, type, user); + return notificationRepository.save(notification); + } + + /** + * 사용자의 모든 알림을 조회합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 로그인 한 사용자의 모든 알림이 담긴 List + */ + public List findAllNotification(User loginUser) { + return notificationRepository.findAllByReceiverId(loginUser.getId()) + .stream() + .map(NotificationResponseDto::toDto) + .toList(); + } + + /** + * 단일 알림을 읽음 처리합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @param id 읽음 처리할 알림 id + */ + @Transactional + public void readNotification(User loginUser, Long id) { + Notification notification = notificationRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.NOTIFICATION_NOT_FOUND)); + + if (!Objects.equals(notification.getReceiver().getId(), loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } // 알림의 받는 사람과 로그인 한 유저가 다르면 예외 처리 + + notification.updateIsRead(true); + } + + /** + * 로그인한 사용자의 읽지 않은 모든 알림을 읽음 처리합니다. + * @param loginUser 현재 인증된 사용자 정보 + */ + @Transactional + public void readAllNotification(User loginUser) { + notificationRepository.updateUnreadNotificationToRead(loginUser.getId()); + } + + /** + * SSE 연결을 구독합니다. 또한 가장 최근 읽지 않은 알림 1개를 전송합니다. + * @param loginUser 현재 인증된 사용자 정보 + * @return 사용자 연결정보가 담긴 SseEmitter + */ + public SseEmitter subscribe(User loginUser) { + SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); + + try { + // 연결 직후 더미 데이터를 보내 503 에러 방지 + emitter.send(SseEmitter.event() + .name("connect") + .data("connected!")); + + // DB에서 가장 최근 읽지 않은 알림 1개만 조회 + notificationRepository.findTopByReceiverIdAndIsReadOrderByCreatedAtDesc(loginUser.getId(), false) + .ifPresent(notification -> { + try { + NotificationResponseDto notificationDto = NotificationResponseDto.toDto(notification); + emitter.send(SseEmitter.event() + .name(notification.getType().name()) + .data(notificationDto)); + log.debug("최근 알림 전송 - ID: {}", notification.getId()); + } catch (IOException e) { + log.error("알림 전송 실패 - ID: {} - 에러: {}", notification.getId(), e.getMessage()); + } + }); + + } catch (IOException e) { + log.error("SSE 연결 실패 - 유저: {} - 에러: {}", loginUser.getId(), e.getMessage()); + throw new RuntimeException("SSE 연결 실패", e); + } + + return emitterRepository.save(loginUser.getId(), emitter); + } + + /** + * 사용자에게 알림을 전송합니다. + * @param notification 보내질 알림 + */ + @Transactional + public void sendNotification(Notification notification) { + NotificationResponseDto notificationDto = NotificationResponseDto.toDto(notification); + + // Redis Stream 에 알림 추가 + redisStreamService.addNotificationToStream(notificationDto); + } +} diff --git a/src/main/java/com/example/gamemate/domain/notification/service/RedisStreamService.java b/src/main/java/com/example/gamemate/domain/notification/service/RedisStreamService.java new file mode 100644 index 0000000..d48b630 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/notification/service/RedisStreamService.java @@ -0,0 +1,200 @@ +package com.example.gamemate.domain.notification.service; + +import com.example.gamemate.domain.notification.dto.NotificationResponseDto; +import com.example.gamemate.domain.notification.repository.EmitterRepository; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.time.Duration; +import java.util.*; + +@Service +@Slf4j +public class RedisStreamService { + + private static final String STREAM_KEY = "notification_stream"; + private static final String GROUP_NAME = "notification-group"; + + private static final String CONSUMER_PREFIX = "consumer"; + private static final int BATCH_SIZE = 100; + private static final Duration POLL_TIMEOUT = Duration.ofMillis(100); + private static final int MAX_STREAM_LENGTH = 1000; + + private final RedisTemplate redisTemplate; + private final EmitterRepository emitterRepository; + + public RedisStreamService( + @Qualifier("notificationRedisTemplate") RedisTemplate redisTemplate, + EmitterRepository emitterRepository) { + this.redisTemplate = redisTemplate; + this.emitterRepository = emitterRepository; + } + + @PostConstruct + public void init() { + createStreamGroup(); + initializeStreamTrimming(); + } + + public void createStreamGroup() { + try { + // 스트림이 없으면 생성 + if (!Boolean.TRUE.equals(redisTemplate.hasKey(STREAM_KEY))) { + redisTemplate.opsForStream().createGroup(STREAM_KEY, ReadOffset.from("0-0"), GROUP_NAME); + log.info("스트림과 그룹 생성 완료: {}", GROUP_NAME); + } + // 스트림은 있지만 그룹이 없는 경우 + else { + try { + redisTemplate.opsForStream().createGroup(STREAM_KEY, GROUP_NAME); + log.info("기존 스트림에 그룹 생성 완료: {}", GROUP_NAME); + } catch (Exception e) { + log.info("그룹이 이미 존재합니다: {}", e.getMessage()); + } + } + } catch (Exception e) { + log.error("스트림 그룹 생성 중 오류 발생: {}", e.getMessage()); + } + } + + private void initializeStreamTrimming() { + // 스트림 크기 제한 설정 + try { + redisTemplate.opsForStream().trim(STREAM_KEY, MAX_STREAM_LENGTH); + } catch (Exception e) { + log.error("스트림 크기 초기화 중 오류 발생: {}", e.getMessage()); + } + } + + public void addNotificationToStream(NotificationResponseDto notification) { + try { + Map notificationMap = new HashMap<>(); + notificationMap.put("id", notification.getId().toString()); + notificationMap.put("content", notification.getContent()); + notificationMap.put("type", notification.getType().name()); + notificationMap.put("relatedUrl", notification.getRelatedUrl()); + notificationMap.put("receiverId", notification.getReceiverId().toString()); + notificationMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + + RecordId recordId = redisTemplate.opsForStream() + .add(StreamRecords.newRecord() + .in(STREAM_KEY) + .ofMap(notificationMap)); + + log.info("알림이 스트림에 추가됨: {}", recordId); + + // 스트림 크기 관리 + manageStream(); + } catch (Exception e) { + log.error("알림 스트림 저장 실패: {}", e.getMessage()); + throw new RuntimeException("알림 저장 실패", e); + } + } + + private void manageStream() { + try { + // 스트림 길이 제한 + long length = redisTemplate.opsForStream().size(STREAM_KEY); + if (length > MAX_STREAM_LENGTH) { + redisTemplate.opsForStream().trim(STREAM_KEY, MAX_STREAM_LENGTH); + log.info("스트림 크기 조정 완료. 현재 크기: {}", length); + } + } catch (Exception e) { + log.error("스트림 관리 중 오류 발생: {}", e.getMessage()); + } + } + + @Scheduled(fixedRate = 1000) + public void processUnconsumedNotifications() { + String consumerName = CONSUMER_PREFIX + "-" + UUID.randomUUID().toString(); + + try { + // 처리되지 않은 메시지 읽기 + List> records = + redisTemplate.opsForStream().read( + Consumer.from(GROUP_NAME, consumerName), + StreamReadOptions.empty().count(BATCH_SIZE).block(POLL_TIMEOUT), + StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())); + + for (MapRecord record : records) { + try { + processNotification(record); + // 성공적으로 처리된 메시지 승인 + redisTemplate.opsForStream() + .acknowledge(GROUP_NAME, record); + } catch (Exception e) { + log.error("알림 처리 실패: {}", e.getMessage()); + // 실패한 메시지 재처리 큐에 추가 + handleFailedNotification(record); + } + } + } catch (Exception e) { + log.error("알림 처리 중 오류 발생: {}", e.getMessage()); + } + } + + private void processNotification(MapRecord record) throws IOException { + Map value = record.getValue(); + Long receiverId = Long.parseLong(value.get("receiverId").toString()); + + // 연결된 SSE Emitter 찾기 + SseEmitter emitter = emitterRepository.findById(receiverId); + if (emitter != null) { + try { + // SSE로 알림 전송 + emitter.send(SseEmitter.event() + .name(value.get("type").toString()) + .data(value)); + } catch (Exception e) { + log.error("SSE 알림 전송 실패: {}", e.getMessage()); + emitterRepository.deleteById(receiverId); + throw e; + } + } + } + + private void handleFailedNotification(MapRecord record) { + // 실패한 메시지를 재처리 큐에 추가하는 로직 + try { + String failedStreamKey = STREAM_KEY + "-failed"; + redisTemplate.opsForStream() + .add(StreamRecords.newRecord() + .in(failedStreamKey) + .ofMap(new HashMap<>(record.getValue()))); + } catch (Exception e) { + log.error("실패한 알림 처리 중 오류: {}", e.getMessage()); + } + } + + @Scheduled(fixedRate = 5000) + public void retryFailedNotifications() { + String failedStreamKey = STREAM_KEY + "-failed"; + + try { + List> failedRecords = + redisTemplate.opsForStream() + .read(StreamOffset.fromStart(failedStreamKey)); + + for (MapRecord record : failedRecords) { + try { + // 실패한 메시지 재처리 + processNotification(record); + // 성공적으로 처리된 메시지 제거 + redisTemplate.opsForStream() + .delete(failedStreamKey, record.getId()); + } catch (Exception e) { + log.error("실패한 알림 재처리 실패: {}", e.getMessage()); + } + } + } catch (Exception e) { + log.error("실패한 알림 재처리 중 오류 발생: {}", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/domain/reply/controller/ReplyController.java b/src/main/java/com/example/gamemate/domain/reply/controller/ReplyController.java new file mode 100644 index 0000000..476e3e8 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/reply/controller/ReplyController.java @@ -0,0 +1,73 @@ +package com.example.gamemate.domain.reply.controller; + +import com.example.gamemate.domain.reply.dto.ReplyRequestDto; +import com.example.gamemate.domain.reply.dto.ReplyResponseDto; +import com.example.gamemate.domain.reply.service.ReplyService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/boards/{boardId}/comments/{commentId}/replies") +public class ReplyController { + + private final ReplyService replyService; + + /** + * 대댓글 생성 API 입니다. + * + * @param commentId 댓글 식별자 + * @param requestDto 대댓글 생성 Dto + * @param customUserDetails 인증된 사용자 + * @return 대댓글 생성 ResponseEntity + */ + @PostMapping + public ResponseEntity createReply( + @PathVariable Long commentId, + @Valid @RequestBody ReplyRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + ReplyResponseDto dto = replyService.createReply(customUserDetails.getUser(), commentId, requestDto); + return new ResponseEntity<>(dto, HttpStatus.CREATED); + } + + /** + * 대댓글 수정 API 입니다. + * + * @param id 대댓글 식별자 + * @param requestDto 업데이트할 대댓글 Dto + * @param customUserDetails 인증된 사용자 + * @return Void + */ + @PatchMapping("/{id}") + public ResponseEntity updateReply( + @PathVariable Long id, + @Valid @RequestBody ReplyRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + replyService.updateReply(customUserDetails.getUser(), id, requestDto); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 대댓글 삭제 API 입니다. + * + * @param id 대댓글 식별자 + * @param customUserDetails 인증된 사용자 + * @return Void + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteReply( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ){ + replyService.deleteReply(customUserDetails.getUser(), id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} + diff --git a/src/main/java/com/example/gamemate/domain/reply/dto/ReplyFindResponseDto.java b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyFindResponseDto.java new file mode 100644 index 0000000..2806e74 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyFindResponseDto.java @@ -0,0 +1,25 @@ +package com.example.gamemate.domain.reply.dto; + +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Getter +public class ReplyFindResponseDto { + private final Long replyId; + private final String parentReplyName; + private final String content; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + + public ReplyFindResponseDto(Long replyId, String parentReplyName, String content, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.replyId = replyId; + this.parentReplyName = parentReplyName; + this.content = content; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/example/gamemate/domain/reply/dto/ReplyRequestDto.java b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyRequestDto.java new file mode 100644 index 0000000..6c038a3 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyRequestDto.java @@ -0,0 +1,21 @@ +package com.example.gamemate.domain.reply.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class ReplyRequestDto { + @NotBlank(message="댓글 내용을 입력하세요.") + private String content; + + private Long parentReplyId; + + public ReplyRequestDto(String content, Long parentReplyId) { + this.content = content; + this.parentReplyId = parentReplyId; + } + + public ReplyRequestDto() { + } +} diff --git a/src/main/java/com/example/gamemate/domain/reply/dto/ReplyResponseDto.java b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyResponseDto.java new file mode 100644 index 0000000..809a1c3 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/reply/dto/ReplyResponseDto.java @@ -0,0 +1,32 @@ +package com.example.gamemate.domain.reply.dto; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ReplyResponseDto { + private final Long id; + private final Long commentId; + private Long parentReplyId; + private final String content; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public ReplyResponseDto(Long id, Long commentId, Long parentReplyId, String content, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.commentId = commentId; + this.parentReplyId = parentReplyId; + this.content = content; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public ReplyResponseDto(Long id, Long commentId, String content, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.commentId = commentId; + this.content = content; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/example/gamemate/domain/reply/entity/Reply.java b/src/main/java/com/example/gamemate/domain/reply/entity/Reply.java new file mode 100644 index 0000000..b970fd9 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/reply/entity/Reply.java @@ -0,0 +1,52 @@ +package com.example.gamemate.domain.reply.entity; + +import com.example.gamemate.domain.comment.entity.Comment; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "reply") +public class Reply extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String content; + + @ManyToOne + @JoinColumn(name = "comment_id") + private Comment comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_reply_id") + private Reply parentReply; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + public Reply(String content, Comment comment, User user) { + this.content = content; + this.comment = comment; + this.user = user; + } + + public Reply(String content, Comment comment, User user, Reply parentReply) { + this.content = content; + this.comment = comment; + this.user = user; + this.parentReply = parentReply; + } + + public void updateReply(String content){ + this.content = content; + } +} diff --git a/src/main/java/com/example/gamemate/domain/reply/repository/ReplyRepository.java b/src/main/java/com/example/gamemate/domain/reply/repository/ReplyRepository.java new file mode 100644 index 0000000..69bf6fe --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/reply/repository/ReplyRepository.java @@ -0,0 +1,13 @@ +package com.example.gamemate.domain.reply.repository; + +import com.example.gamemate.domain.comment.entity.Comment; +import com.example.gamemate.domain.reply.entity.Reply; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReplyRepository extends JpaRepository { + List findByComment(Comment comment); + + List findByParentReply(Reply reply); +} diff --git a/src/main/java/com/example/gamemate/domain/reply/service/ReplyService.java b/src/main/java/com/example/gamemate/domain/reply/service/ReplyService.java new file mode 100644 index 0000000..fb04178 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/reply/service/ReplyService.java @@ -0,0 +1,117 @@ +package com.example.gamemate.domain.reply.service; + +import com.example.gamemate.domain.comment.entity.Comment; +import com.example.gamemate.domain.comment.repository.CommentRepository; +import com.example.gamemate.domain.reply.dto.ReplyRequestDto; +import com.example.gamemate.domain.reply.dto.ReplyResponseDto; +import com.example.gamemate.domain.reply.entity.Reply; +import com.example.gamemate.domain.reply.repository.ReplyRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.eventListener.event.MatchCreatedEvent; +import com.example.gamemate.global.eventListener.event.ReplyCreatedEvent; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReplyService { + + private final ReplyRepository replyRepository; + private final CommentRepository commentRepository; + private final ApplicationEventPublisher publisher; + + /** + * 대댓글 생성 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param commentId 댓글 식별자 + * @param requestDto 댓글 생성 Dto + * @return 대댓글 생성 정보 Dto + */ + @Transactional + public ReplyResponseDto createReply(User loginUser, Long commentId, ReplyRequestDto requestDto) { + //댓글 조회 + Comment findComment = commentRepository.findById(commentId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + Reply newReply; + // 부모 대댓글 null 일 경우 + if(requestDto.getParentReplyId()==null){ + newReply = new Reply(requestDto.getContent(), findComment, loginUser); + Reply createReply = replyRepository.save(newReply); + publisher.publishEvent(new ReplyCreatedEvent(this, createReply)); + + return new ReplyResponseDto( + createReply.getId(), + createReply.getComment().getId(), + createReply.getContent(), + createReply.getCreatedAt(), + createReply.getModifiedAt() + ); + }else{ + //대댓글 조회 + Reply findParentReply = replyRepository.findById(requestDto.getParentReplyId()) + .orElseThrow(()-> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + newReply = new Reply(requestDto.getContent(), findComment, loginUser, findParentReply); + Reply createReply = replyRepository.save(newReply); + publisher.publishEvent(new ReplyCreatedEvent(this, createReply)); + + return new ReplyResponseDto( + createReply.getId(), + createReply.getComment().getId(), + createReply.getParentReply().getId(), + createReply.getContent(), + createReply.getCreatedAt(), + createReply.getModifiedAt() + ); + } + } + + /** + * 대댓글 업데이트 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param id 대댓글 식별자 + * @param requestDto 대댓글 업데이트 Dto + */ + @Transactional + public void updateReply(User loginUser, Long id, ReplyRequestDto requestDto) { + // 대댓글 조회 + Reply findReply = replyRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + // 대댓글 작성자와 로그인 유저 확인 + if(!findReply.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + findReply.updateReply(requestDto.getContent()); + replyRepository.save(findReply); + } + + /** + * 대댓글 메서드입니다. + * + * @param loginUser 로그인한 유저 + * @param id 대댓글 식별자 + */ + @Transactional + public void deleteReply(User loginUser, Long id) { + // 대댓글 조회 + Reply findReply = replyRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + // 대댓글 작성자와 로그인 유저 확인 + if(!findReply.getUser().getId().equals(loginUser.getId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + replyRepository.delete(findReply); + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/controller/ReviewController.java b/src/main/java/com/example/gamemate/domain/review/controller/ReviewController.java new file mode 100644 index 0000000..b7f8709 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/controller/ReviewController.java @@ -0,0 +1,102 @@ +package com.example.gamemate.domain.review.controller; + +import com.example.gamemate.domain.review.dto.request.ReviewCreateRequestDto; +import com.example.gamemate.domain.review.dto.request.ReviewUpdateRequestDto; +import com.example.gamemate.domain.review.dto.response.ReviewCreateResponseDto; +import com.example.gamemate.domain.review.dto.response.ReviewFindByAllResponseDto; +import com.example.gamemate.domain.review.service.ReviewService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * 게임 리뷰 관련 API를 처리하는 컨트롤러 클래스입니다. + * 이 컨트롤러는 리뷰의 생성, 수정, 삭제 및 조회 기능을 제공합니다. + */ +@RestController +@RequestMapping("/games/{gameId}/reviews") +@Slf4j +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + /** + * 새로운 게임 리뷰를 생성합니다. + * + * @param gameId 리뷰를 작성할 게임의 ID + * @param requestDto 리뷰 생성 요청 데이터 + * @param customUserDetails 인증된 사용자 정보 + * @return 생성된 리뷰 정보를 포함한 ResponseEntity + */ + @PostMapping + public ResponseEntity createReview( + @PathVariable Long gameId, + @Valid @RequestBody ReviewCreateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + ReviewCreateResponseDto responseDto = reviewService.createReview(customUserDetails.getUser(), gameId, requestDto); + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + /** + * 기존 게임 리뷰를 수정합니다. + * + * @param gameId 리뷰가 속한 게임의 ID + * @param id 수정할 리뷰의 ID + * @param requestDto 리뷰 수정 요청 데이터 + * @param customUserDetails 인증된 사용자 정보 + * @return 수정 결과를 나타내는 ResponseEntity + */ + @PatchMapping("/{id}") + public ResponseEntity updateReview( + @PathVariable Long gameId, + @PathVariable Long id, + @Valid @RequestBody ReviewUpdateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + reviewService.updateReview(customUserDetails.getUser(), gameId, id, requestDto); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 게임 리뷰를 삭제합니다. + * + * @param gameId 리뷰가 속한 게임의 ID + * @param id 삭제할 리뷰의 ID + * @param customUserDetails 인증된 사용자 정보 + * @return 삭제 결과를 나타내는 ResponseEntity + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteReview( + @PathVariable Long gameId, + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + reviewService.deleteReview(customUserDetails.getUser(), id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 특정 게임의 모든 리뷰를 조회합니다. + * + * @param gameId 리뷰를 조회할 게임의 ID + * @param customUserDetails 인증된 사용자 정보 + * @return 게임의 모든 리뷰 목록을 포함한 ResponseEntity + */ + @GetMapping + public ResponseEntity> ReviewFindAllByGameId( + @PathVariable Long gameId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Page responseDto = reviewService.ReviewFindAllByGameId(gameId, customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/dto/request/ReviewCreateRequestDto.java b/src/main/java/com/example/gamemate/domain/review/dto/request/ReviewCreateRequestDto.java new file mode 100644 index 0000000..dd102b6 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/dto/request/ReviewCreateRequestDto.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.review.dto.request; + +import lombok.Getter; + +@Getter +public class ReviewCreateRequestDto { + + private String content; + private Integer star; + private Long gameId; // Game 엔티티 대신 gameId만 전달 + private Long userId; + + public ReviewCreateRequestDto(String content, Integer star, Long gameId, Long userId) { + this.content = content; + this.star = star; + this.gameId = gameId; + this.userId = userId; + + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/dto/request/ReviewUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/review/dto/request/ReviewUpdateRequestDto.java new file mode 100644 index 0000000..0cf8915 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/dto/request/ReviewUpdateRequestDto.java @@ -0,0 +1,20 @@ +package com.example.gamemate.domain.review.dto.request; + +import lombok.Getter; + +@Getter +public class ReviewUpdateRequestDto { + + private String content; + private Integer star; + private Long gameId; // Game 엔티티 대신 gameId만 전달 + private Long userId; + + public ReviewUpdateRequestDto(String content, Integer star, Long gameId, Long userId) { + this.content = content; + this.star = star; + this.gameId = gameId; + this.userId = userId; + + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewCreateResponseDto.java b/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewCreateResponseDto.java new file mode 100644 index 0000000..7334063 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewCreateResponseDto.java @@ -0,0 +1,25 @@ +package com.example.gamemate.domain.review.dto.response; + +import com.example.gamemate.domain.review.entity.Review; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ReviewCreateResponseDto { + private Long id; + private String content; + private Integer star; + private Long gameId; + private Long userId; + private LocalDateTime createdAt; + + public ReviewCreateResponseDto(Review review) { + this.id = review.getId(); + this.content = review.getContent(); + this.star = review.getStar(); + this.gameId = review.getGame().getId(); + this.userId = review.getUser().getId(); + this.createdAt = review.getCreatedAt(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewFindByAllResponseDto.java b/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewFindByAllResponseDto.java new file mode 100644 index 0000000..a54de24 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewFindByAllResponseDto.java @@ -0,0 +1,29 @@ +package com.example.gamemate.domain.review.dto.response; + +import com.example.gamemate.domain.review.entity.Review; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ReviewFindByAllResponseDto { + private Long id; + private String content; + private Integer star; + private Long gameId; + private Long userId; + private LocalDateTime createdAt; + private String nickName; + private Long likeCount; + + public ReviewFindByAllResponseDto(Review review, String nickName, Long likeCount) { + this.id = review.getId(); + this.content = review.getContent(); + this.star = review.getStar(); + this.gameId = review.getGame().getId(); + this.userId = review.getUser().getId(); + this.createdAt = review.getCreatedAt(); + this.nickName = nickName; + this.likeCount = likeCount; + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewUpdateResponseDto.java b/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewUpdateResponseDto.java new file mode 100644 index 0000000..f56ef5c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/dto/response/ReviewUpdateResponseDto.java @@ -0,0 +1,25 @@ +package com.example.gamemate.domain.review.dto.response; + +import com.example.gamemate.domain.review.entity.Review; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ReviewUpdateResponseDto { + private Long id; + private String content; + private Integer star; + private Long gameId; + private Long userId; + private LocalDateTime modifiedAt; + + public ReviewUpdateResponseDto(Review review) { + this.id = review.getId(); + this.content = review.getContent(); + this.star = review.getStar(); + this.gameId = review.getGame().getId(); + this.userId = review.getUser().getId(); + this.modifiedAt = review.getModifiedAt(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/entity/Review.java b/src/main/java/com/example/gamemate/domain/review/entity/Review.java new file mode 100644 index 0000000..fd8cba3 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/entity/Review.java @@ -0,0 +1,46 @@ +package com.example.gamemate.domain.review.entity; + +import com.example.gamemate.domain.game.entity.Game; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "review") +public class Review extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content", nullable = false) + private String content; + + @Column(name = "star", nullable = false) + private Integer star; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "game_id") + private Game game; + + public Review(String content, Integer star, Game gameId, User userId) { + this.content = content; + this.star = star; + this.game = gameId; + this.user = userId; + } + + public void updateReview(String content, Integer star) { + this.content = content; + this.star = star; + + } +} diff --git a/src/main/java/com/example/gamemate/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/gamemate/domain/review/repository/ReviewRepository.java new file mode 100644 index 0000000..807788f --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/repository/ReviewRepository.java @@ -0,0 +1,15 @@ +package com.example.gamemate.domain.review.repository; + +import com.example.gamemate.domain.game.entity.Game; +import com.example.gamemate.domain.review.entity.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { + + Page findAllByGame(Game game, Pageable pageable); + + boolean existsByUserIdAndGameId(Long userId, Long gameId); + +} diff --git a/src/main/java/com/example/gamemate/domain/review/service/ReviewService.java b/src/main/java/com/example/gamemate/domain/review/service/ReviewService.java new file mode 100644 index 0000000..48d7b2c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/review/service/ReviewService.java @@ -0,0 +1,117 @@ +package com.example.gamemate.domain.review.service; + +import com.example.gamemate.domain.game.entity.Game; +import com.example.gamemate.domain.game.repository.GameRepository; +import com.example.gamemate.domain.like.enums.LikeStatus; +import com.example.gamemate.domain.like.repository.ReviewLikeRepository; +import com.example.gamemate.domain.review.dto.request.ReviewCreateRequestDto; +import com.example.gamemate.domain.review.dto.response.ReviewCreateResponseDto; +import com.example.gamemate.domain.review.dto.response.ReviewFindByAllResponseDto; +import com.example.gamemate.domain.review.dto.request.ReviewUpdateRequestDto; +import com.example.gamemate.domain.review.entity.Review; +import com.example.gamemate.domain.review.repository.ReviewRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.Role; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +public class ReviewService { + private final ReviewRepository reviewRepository; + private final GameRepository gameRepository; + private final UserRepository userRepository; + private final ReviewLikeRepository reviewLikeRepository; + + //리뷰 생성 + @Transactional + public ReviewCreateResponseDto createReview(User loginUser, Long gameId, ReviewCreateRequestDto requestDto) { + + Long userId = loginUser.getId(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + boolean hasReview = reviewRepository.existsByUserIdAndGameId(userId, gameId); + if (hasReview) { + throw new ApiException(ErrorCode.REVIEW_ALREADY_EXISTS); + } + + Game game = gameRepository.findById(gameId) + .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + + Review review = new Review( + requestDto.getContent(), + requestDto.getStar(), + game, + user + ); + + Review saveReview = reviewRepository.save(review); + return new ReviewCreateResponseDto(saveReview); + } + + //리뷰 수정 + @Transactional + public void updateReview(User loginUser, Long gameId, Long id, ReviewUpdateRequestDto requestDto) { + + Long userId = loginUser.getId(); + + Review review = reviewRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.REVIEW_NOT_FOUND)); + + if (!review.getUser().getId().equals(userId)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + review.updateReview( + requestDto.getContent(), + requestDto.getStar() + ); + + reviewRepository.save(review); + } + + //리뷰 삭제 + @Transactional + public void deleteReview(User loginUser, Long id) { + + Long userId = loginUser.getId(); + + Review review = reviewRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.REVIEW_NOT_FOUND)); + + if (userId.equals(review.getUser().getId()) || loginUser.getRole() == Role.ADMIN) { + reviewRepository.delete(review); + } else { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + } + + //리뷰 조회(게임별 다건 조회) + public Page ReviewFindAllByGameId(Long gameId, User loginUser) { + + Game game = gameRepository.findGameById(gameId) + .orElseThrow(() -> new ApiException(ErrorCode.GAME_NOT_FOUND)); + + Pageable pageable = PageRequest.of(0, 5, Sort.by("createdAt").descending()); + Page reviewPage = reviewRepository.findAllByGame(game, pageable); + + return reviewPage.map(review -> { + Long likeCount = reviewLikeRepository.countByReviewIdAndStatus(review.getId(), LikeStatus.LIKE); + return new ReviewFindByAllResponseDto(review, loginUser.getNickname(), likeCount); + }); + } + + +} diff --git a/src/main/java/com/example/gamemate/domain/user/controller/UserController.java b/src/main/java/com/example/gamemate/domain/user/controller/UserController.java new file mode 100644 index 0000000..54052b7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/controller/UserController.java @@ -0,0 +1,98 @@ +package com.example.gamemate.domain.user.controller; + +import com.example.gamemate.domain.auth.service.AuthService; +import com.example.gamemate.domain.user.dto.PasswordUpdateRequestDto; +import com.example.gamemate.domain.user.dto.ProfileResponseDto; +import com.example.gamemate.domain.user.dto.ProfileUpdateRequestDto; +import com.example.gamemate.domain.user.service.UserService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * 사용자와 관련된 요청을 처리하는 컨트롤러입니다. + * 프로필 조회, 프로필 수정, 비밀번호 변경, 회원 탈퇴 기능을 제공합니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + private final UserService userService; + private final AuthService authService; + + /** + * 사용자 프로필을 조회합니다. + * @param id 조회할 사용자의 ID + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 조회된 사용자 프로필 정보 + */ + @GetMapping("/{id}") + public ResponseEntity findProfile( + @PathVariable Long id, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + ProfileResponseDto responseDto = userService.findProfile(id, customUserDetails.getUser()); + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + /** + * 사용자의 프로필을 수정합니다. + * @param id 수정할 사용자의 ID + * @param requestDto 프로필 수정 요청 정보 + * (새 닉네임) + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 처리 결과 상태 코드 + */ + @PatchMapping("/{id}") + public ResponseEntity updateProfile( + @PathVariable Long id, + @Valid @RequestBody ProfileUpdateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + userService.updateProfile(id, requestDto.getNewNickname(), customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 사용자의 비밀번호를 변경합니다. + * @param id 변경할 사용자의 ID + * @param requestDto 비밀번호 변경 요청 정보 + * (기존 비밀번호, 새 비밀번호) + * @param customUserDetails 현재 인증된 사용자 정보 + * @return 처리 결과 상태 코드 + */ + @PatchMapping("/{id}/password") + public ResponseEntity updatePassword( + @PathVariable Long id, + @Valid @RequestBody PasswordUpdateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + userService.updatePassword(id, requestDto.getOldPassword(), requestDto.getNewPassword(), customUserDetails.getUser()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * 사용자의 탈퇴 요청을 처리합니다. + * @param customUserDetails 현재 인증된 사용자 정보 + * @param request HTTP 요청 객체 + * @param response HTTP 응답 객체 + * @return 처리 결과 상태 코드 + */ + @DeleteMapping("/withdraw") + public ResponseEntity withdraw( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + HttpServletRequest request, + HttpServletResponse response + ) { + userService.withdrawUser(customUserDetails.getUser(), request, response); + authService.logout(customUserDetails.getUser(), request, response); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/gamemate/domain/user/dto/PasswordUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/user/dto/PasswordUpdateRequestDto.java new file mode 100644 index 0000000..9468e34 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/dto/PasswordUpdateRequestDto.java @@ -0,0 +1,21 @@ +package com.example.gamemate.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class PasswordUpdateRequestDto { + + @NotBlank(message = "기존 비밀번호를 입력해주세요.") + private final String oldPassword; + + @NotBlank(message = "새로운 비밀번호를 입력해주세요.") +// @Size(min = 8, message = "비밀번호는 8글자 이상으로 입력해주세요.") +// @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$", message = "비밀번호는 대소문자 포함 영문 + 숫자 + 특수문자 최소 1글자씩 입력해주세요.") + private final String newPassword; + +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/domain/user/dto/ProfileResponseDto.java b/src/main/java/com/example/gamemate/domain/user/dto/ProfileResponseDto.java new file mode 100644 index 0000000..9d87ac0 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/dto/ProfileResponseDto.java @@ -0,0 +1,22 @@ +package com.example.gamemate.domain.user.dto; + +import com.example.gamemate.domain.user.entity.User; +import lombok.Getter; + +@Getter +public class ProfileResponseDto { + + private final Long id; + private final String email; + private final String name; + private final String nickname; + private final Boolean is_premium; + + public ProfileResponseDto(User user) { + this.id = user.getId(); + this.email = user.getEmail(); + this.name = user.getName(); + this.nickname = user.getNickname(); + this.is_premium = user.getIsPremium(); + } +} diff --git a/src/main/java/com/example/gamemate/domain/user/dto/ProfileUpdateRequestDto.java b/src/main/java/com/example/gamemate/domain/user/dto/ProfileUpdateRequestDto.java new file mode 100644 index 0000000..612a1f7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/dto/ProfileUpdateRequestDto.java @@ -0,0 +1,14 @@ +package com.example.gamemate.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ProfileUpdateRequestDto { + + @NotBlank(message = "새로운 닉네임을 입력해주세요.") + private final String newNickname; + +} diff --git a/src/main/java/com/example/gamemate/domain/user/entity/User.java b/src/main/java/com/example/gamemate/domain/user/entity/User.java new file mode 100644 index 0000000..429676f --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/entity/User.java @@ -0,0 +1,103 @@ +package com.example.gamemate.domain.user.entity; + +import com.example.gamemate.domain.coupon.entity.UserCoupon; +import com.example.gamemate.domain.follow.entity.Follow; +import com.example.gamemate.domain.user.enums.AuthProvider; +import com.example.gamemate.global.common.BaseEntity; +import com.example.gamemate.domain.user.enums.Role; +import com.example.gamemate.domain.user.enums.UserStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "`user`") +@Getter +@NoArgsConstructor +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private Role role; + + private Boolean isPremium; + + @Enumerated(EnumType.STRING) + private UserStatus userStatus; + + @Enumerated(EnumType.STRING) + private AuthProvider provider; + + private String providerId; + + @OneToMany(mappedBy = "follower") + private List followingList; + + @OneToMany(mappedBy = "followee") + private List followerList; + + @OneToMany(mappedBy = "user") + private List userCoupons = new ArrayList<>(); + + // 이메일 로그인용 생성자 + public User(String email, String name, String nickname, String password) { + this.email = email; + this.name = name; + this.nickname = nickname; + this.password = password; + this.provider = AuthProvider.LOCAL; + this.providerId = null; + this.role = Role.USER; + this.isPremium = false; + this.userStatus = UserStatus.ACTIVE; + } + + // OAuth용 생성자 + public User(String email, String name, String nickname, AuthProvider provider, String providerId) { + this.email = email; + this.name = name; + this.nickname = nickname; + this.password = "OAUTH2_USER"; + this.provider = provider; + this.providerId = providerId; + this.role = Role.USER; + this.isPremium = false; + this.userStatus = UserStatus.ACTIVE; + } + + public void updatePassword(String newPassword) { + this.password = newPassword; + } + + public void updateProfile(String newNickname) { + this.nickname = newNickname; + } + + public void updateUserStatus(UserStatus status) { + this.userStatus = status; + } + + public void integrateOAuthProvider(AuthProvider provider, String providerId) { + this.provider = provider; + this.providerId = providerId; + } + +} diff --git a/src/main/java/com/example/gamemate/domain/user/enums/AuthProvider.java b/src/main/java/com/example/gamemate/domain/user/enums/AuthProvider.java new file mode 100644 index 0000000..04a45f7 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/enums/AuthProvider.java @@ -0,0 +1,29 @@ +package com.example.gamemate.domain.user.enums; + +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import lombok.Getter; + +@Getter +public enum AuthProvider { + + LOCAL("local"), + GOOGLE("google"), + KAKAO("kakao"); + + private final String name; + + AuthProvider(String name) { + this.name = name; + } + + public static AuthProvider fromString(String provider) { + for (AuthProvider authProvider : values()) { + if (authProvider.getName().equalsIgnoreCase(provider)) { + return authProvider; + } + } + throw new ApiException(ErrorCode.INVALID_PROVIDER_TYPE); + } + +} diff --git a/src/main/java/com/example/gamemate/domain/user/enums/Role.java b/src/main/java/com/example/gamemate/domain/user/enums/Role.java new file mode 100644 index 0000000..33d72f0 --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/enums/Role.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.user.enums; + +import lombok.Getter; + +@Getter +public enum Role { + + USER("user"), + ADMIN("admin"); + + private final String name; + + Role(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/example/gamemate/domain/user/enums/UserStatus.java b/src/main/java/com/example/gamemate/domain/user/enums/UserStatus.java new file mode 100644 index 0000000..a02238e --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/enums/UserStatus.java @@ -0,0 +1,16 @@ +package com.example.gamemate.domain.user.enums; + +import lombok.Getter; + +@Getter +public enum UserStatus { + + ACTIVE("active"), + WITHDRAW("withdraw"); + + private final String name; + + UserStatus(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/example/gamemate/domain/user/repository/UserRepository.java b/src/main/java/com/example/gamemate/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..d07278c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.example.gamemate.domain.user.repository; + +import com.example.gamemate.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/example/gamemate/domain/user/service/UserService.java b/src/main/java/com/example/gamemate/domain/user/service/UserService.java new file mode 100644 index 0000000..4c69d4c --- /dev/null +++ b/src/main/java/com/example/gamemate/domain/user/service/UserService.java @@ -0,0 +1,115 @@ +package com.example.gamemate.domain.user.service; + +import com.example.gamemate.domain.auth.service.AuthService; +import com.example.gamemate.domain.user.dto.ProfileResponseDto; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.UserStatus; +import com.example.gamemate.domain.user.repository.UserRepository; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import com.example.gamemate.global.provider.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 사용자 관련 비즈니스 로직을 처리하는 서비스 클래스입니다. + * 프로필 조회, 프로필 수정, 비밀번호 변경, 회원 탈퇴 등의 작업을 수행합니다. + */ +@Service +@RequiredArgsConstructor +@Transactional +public class UserService { + + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; + private final AuthService authService; + + /** + * 사용자의 프로필을 조회합니다. + * @param id 조회할 사용자의 ID + * @param loginUser 현재 로그인한 사용자 + * @return 조회된 사용자의 프로필 정보 + */ + @Transactional(readOnly = true) + public ProfileResponseDto findProfile(Long id, User loginUser) { + + User findUser = userRepository.findById(id) + .orElseThrow(()-> new ApiException(ErrorCode.USER_NOT_FOUND)); + + if(UserStatus.WITHDRAW.equals(findUser.getUserStatus())) { + throw new ApiException(ErrorCode.IS_WITHDRAWN_USER); + } + return new ProfileResponseDto(findUser); + } + + /** + * 사용자의 프로필을 수정합니다. + * @param id 수정할 사용자의 ID + * @param newNickname 새 닉네임 + * @param loginUser 현재 로그인한 사용자 + */ + public void updateProfile(Long id, String newNickname, User loginUser) { + + User findUser = userRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + validateOwner(findUser, loginUser); + + findUser.updateProfile(newNickname); + userRepository.save(findUser); + + } + + /** + * 사용자의 비밀번호를 변경합니다. + * @param id 변경할 사용자의 ID + * @param oldPassword 기존 비밀번호 + * @param newPassword 새 비밀번호 + * @param loginUser 현재 로그인한 사용자 + */ + public void updatePassword(Long id, String oldPassword, String newPassword, User loginUser) { + + User findUser = userRepository.findById(id) + .orElseThrow(()-> new ApiException(ErrorCode.USER_NOT_FOUND)); + + validateOwner(findUser, loginUser); + + if(!passwordEncoder.matches(oldPassword, findUser.getPassword())) { + throw new ApiException(ErrorCode.INVALID_PASSWORD); + } + + String encodedPassword = passwordEncoder.encode(newPassword); + findUser.updatePassword(encodedPassword); + userRepository.save(findUser); + } + + /** + * 사용자의 탈퇴 요청을 처리합니다. + * @param loginUser 현재 로그인한 사용자 + */ + public void withdrawUser(User loginUser, HttpServletRequest request, HttpServletResponse response) { + + loginUser.updateUserStatus(UserStatus.WITHDRAW); + userRepository.save(loginUser); + + authService.logout(loginUser, request, response); + + } + + /** + * 사용자가 일치하는지 확인합니다. + * @param user 확인할 사용자 + * @param loginUser 현재 로그인한 사용자 + */ + private void validateOwner(User user, User loginUser) { + if(!user.getEmail().equals(loginUser.getEmail())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + } + +} diff --git a/src/main/java/com/example/gamemate/global/common/BaseCreatedEntity.java b/src/main/java/com/example/gamemate/global/common/BaseCreatedEntity.java new file mode 100644 index 0000000..0b9201e --- /dev/null +++ b/src/main/java/com/example/gamemate/global/common/BaseCreatedEntity.java @@ -0,0 +1,21 @@ +package com.example.gamemate.global.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseCreatedEntity { + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + +} diff --git a/src/main/java/com/example/gamemate/global/common/BaseEntity.java b/src/main/java/com/example/gamemate/global/common/BaseEntity.java new file mode 100644 index 0000000..e48fe49 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/common/BaseEntity.java @@ -0,0 +1,29 @@ +package com.example.gamemate.global.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + + public void updateModifiedAt() { + this.modifiedAt = LocalDateTime.now(); + } + +} diff --git a/src/main/java/com/example/gamemate/global/common/aop/DistributedLock.java b/src/main/java/com/example/gamemate/global/common/aop/DistributedLock.java new file mode 100644 index 0000000..bef8942 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/common/aop/DistributedLock.java @@ -0,0 +1,16 @@ +package com.example.gamemate.global.common.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + String key(); + long waitTime() default 5L; + long leaseTime() default 3L; + TimeUnit timeUnit() default TimeUnit.SECONDS; +} diff --git a/src/main/java/com/example/gamemate/global/common/aop/DistributedLockAop.java b/src/main/java/com/example/gamemate/global/common/aop/DistributedLockAop.java new file mode 100644 index 0000000..c7bfd4c --- /dev/null +++ b/src/main/java/com/example/gamemate/global/common/aop/DistributedLockAop.java @@ -0,0 +1,66 @@ +package com.example.gamemate.global.common.aop; + +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.global.constant.ErrorCode; +import com.example.gamemate.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationAdapter; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAop { + private final RedissonClient redissonClient; + private static final String LOCK_PREFIX = "LOCK:"; + + @Around("@annotation(distributedLock)") + public Object executeWithLock( + ProceedingJoinPoint joinPoint, + DistributedLock distributedLock + ) throws Throwable { + String lockKey = LOCK_PREFIX + "coupon:" + joinPoint.getArgs()[0]; + RLock lock = redissonClient.getLock(lockKey); + + log.info("락 획득 시도: {}", lockKey); + boolean isLocked = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); + + if (!isLocked) { + log.warn("락 획득 실패: {}", lockKey); + throw new ApiException(ErrorCode.COUPON_ISSUE_FAILED); + } + + log.info("락 획득 완료: {}", lockKey); + + try { + Object result = joinPoint.proceed(); + + // 트랜잭션이 완료된 후 락 해제 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { + @Override + public void afterCommit() { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("락 해제 완료: {}", lockKey); + } + } + }); + + return result; + } catch (Exception e) { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + throw e; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/global/config/QuerydslConfig.java b/src/main/java/com/example/gamemate/global/config/QuerydslConfig.java new file mode 100644 index 0000000..fe6d172 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/QuerydslConfig.java @@ -0,0 +1,18 @@ +package com.example.gamemate.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/example/gamemate/global/config/RedisConfig.java b/src/main/java/com/example/gamemate/global/config/RedisConfig.java new file mode 100644 index 0000000..f7cfbd0 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/RedisConfig.java @@ -0,0 +1,124 @@ +package com.example.gamemate.global.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + // 기본 RedisConnectionFactory + @Bean + @Primary + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setDatabase(0); + return new LettuceConnectionFactory(config); + } + + // 기본 RedisTemplate + @Bean + @Primary + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory()); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return template; + } + + // DB 1: 알림 + @Bean + public RedisConnectionFactory notificationConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setDatabase(1); + return new LettuceConnectionFactory(config); + } + + // DB 2: 조회수 + @Bean + public RedisConnectionFactory viewCountConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setDatabase(2); + return new LettuceConnectionFactory(config); + } + + // DB 3: 리프레시 토큰 + @Bean + public RedisConnectionFactory refreshTokenConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setDatabase(3); + return new LettuceConnectionFactory(config); + } + + // DB 4: 토큰 블랙리스트 + @Bean + public RedisConnectionFactory blacklistConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setDatabase(4); + return new LettuceConnectionFactory(config); + } + + // 알림 RedisTemplate (DB 1) + @Bean + public RedisTemplate notificationRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(notificationConnectionFactory()); + + // String 타입을 위한 직렬화 설정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + + // 조회수 RedisTemplate (DB 2) + @Bean + public StringRedisTemplate viewCountRedisTemplate() { + return new StringRedisTemplate(viewCountConnectionFactory()); + } + + // 리프레시 토큰 RedisTemplate (DB 3) + @Bean + public StringRedisTemplate refreshTokenRedisTemplate() { + return new StringRedisTemplate(refreshTokenConnectionFactory()); + } + + // 토큰 블랙리스트 RedisTemplate (DB 4) + @Bean + public StringRedisTemplate blacklistRedisTemplate() { + return new StringRedisTemplate(blacklistConnectionFactory()); + } + + // 쿠폰 RedissonClient (DB 5) + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":6379") + .setDatabase(5); + return Redisson.create(config); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/global/config/S3Config.java b/src/main/java/com/example/gamemate/global/config/S3Config.java new file mode 100644 index 0000000..324d4af --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/S3Config.java @@ -0,0 +1,32 @@ +package com.example.gamemate.global.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/example/gamemate/global/config/SecurityConfig.java b/src/main/java/com/example/gamemate/global/config/SecurityConfig.java new file mode 100644 index 0000000..5745f13 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/SecurityConfig.java @@ -0,0 +1,109 @@ +package com.example.gamemate.global.config; + +import com.example.gamemate.global.config.auth.*; +import com.example.gamemate.global.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.HttpMethod; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + *스프링 시큐리티의 인가 및 설정을 담당 + */ +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final DelegatedAuthenticationEntryPoint authenticationEntryPoint; + private final DelegatedAccessDeniedHandler accessDeniedHandler; + private final UserDetailsService userDetailsService; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + private final CustomOAuth2UserService customOAuth2UserService; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf->csrf.disable()) //CSRF 보호 비활성화 (REST API이므로) + .sessionManagement(session-> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/v3/api-docs/**", "/swagger-resources/**" ,"/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/auth/signup", "/auth/login", "/auth/refresh", "/auth/email/**").permitAll() + .requestMatchers("/oauth2/**", "/login/oauth2/**", "/auth/oauth2/**").permitAll() + .requestMatchers("/oauth2-login.html", "/oauth2-login-failure.html", "/oauth2-login-success.html", "/oauth2-set-password.html").permitAll() + .requestMatchers(HttpMethod.GET, "/boards").permitAll() + .requestMatchers(HttpMethod.GET, "/boards/*").permitAll() + .requestMatchers(HttpMethod.GET,"/games", "/games/{id}").hasRole("USER") + .requestMatchers(HttpMethod.POST,"/games/requests").hasRole("USER") + .requestMatchers("/games/recommendations/**").hasRole("USER") + .requestMatchers("/games/requests/**").hasRole("ADMIN") + .requestMatchers("/games/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(endpoint -> endpoint + .baseUri("/oauth2/authorization") + .authorizationRequestRepository(authorizationRequestRepository())) + .redirectionEndpoint(endpoint -> endpoint + .baseUri("/login/oauth2/code/*")) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler)) + .exceptionHandling(hanling-> hanling + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + +// @Bean +// public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { +// return config.getAuthenticationManager(); +// } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); // 여기서 CustomUserDetailsService가 주입됨 + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.fromHierarchy("ROLE_ADMIN > ROLE_USER"); + } + + @Bean + public AuthorizationRequestRepository authorizationRequestRepository() { + return new HttpSessionOAuth2AuthorizationRequestRepository(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/global/config/SwaggerConfig.java b/src/main/java/com/example/gamemate/global/config/SwaggerConfig.java new file mode 100644 index 0000000..88ef8dc --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.example.gamemate.global.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + + Info info = new Info() + .title("Game Mate") + .version("v1.0.0") + .description("Game Mate REST API"); + + return new OpenAPI() + .info(info); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/global/config/aiapi/GeminiRestTemplateConfig.java b/src/main/java/com/example/gamemate/global/config/aiapi/GeminiRestTemplateConfig.java new file mode 100644 index 0000000..cb9b084 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/aiapi/GeminiRestTemplateConfig.java @@ -0,0 +1,21 @@ +package com.example.gamemate.global.config.aiapi; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + + +@Configuration +@RequiredArgsConstructor +public class GeminiRestTemplateConfig { + @Bean + @Qualifier("geminiRestTemplate") + public RestTemplate geminiRestTemplate() { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add((request, body, execution) -> execution.execute(request, body)); + + return restTemplate; + } +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/CustomOAuth2UserService.java b/src/main/java/com/example/gamemate/global/config/auth/CustomOAuth2UserService.java new file mode 100644 index 0000000..19e92e0 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/CustomOAuth2UserService.java @@ -0,0 +1,42 @@ +package com.example.gamemate.global.config.auth; + +import com.example.gamemate.domain.auth.dto.OAuth2LoginResponseDto; +import com.example.gamemate.domain.auth.service.OAuth2Service; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.enums.AuthProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final OAuth2Service oAuth2Service; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oauth2User = super.loadUser(userRequest); + + try { + AuthProvider provider = AuthProvider.valueOf( + userRequest.getClientRegistration().getRegistrationId().toUpperCase() + ); + + OAuth2LoginResponseDto attributes = oAuth2Service.extractOAuth2Attributes( + provider, + oauth2User.getAttributes() + ); + + User user = oAuth2Service.processOAuth2User(attributes); + + return new CustomUserDetails(user, oauth2User.getAttributes()); + + } catch (Exception ex) { + throw new OAuth2AuthenticationException("소셜 로그인 처리 중 오류가 발생했습니다."); + } + } +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/CustomUserDetails.java b/src/main/java/com/example/gamemate/global/config/auth/CustomUserDetails.java new file mode 100644 index 0000000..118b830 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/CustomUserDetails.java @@ -0,0 +1,78 @@ +package com.example.gamemate.global.config.auth; + +import com.example.gamemate.domain.user.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +@Slf4j +public class CustomUserDetails implements UserDetails, OAuth2User { + + private final User user; + private final Map attributes; + + // OAuth2User 구현 + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return user.getEmail(); + } + + // UserDetails 구현 + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + + return authorities; + } + + //비밀번호 + @Override + public String getPassword() { + return user.getPassword(); + } + + //사용자계정 + @Override + public String getUsername() { + return user.getEmail(); + } + + //사용하지 않을 경우 true 리턴 + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/CustomUserDetailsService.java b/src/main/java/com/example/gamemate/global/config/auth/CustomUserDetailsService.java new file mode 100644 index 0000000..42566e2 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package com.example.gamemate.global.config.auth; + +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmail(username) + .orElseThrow(()-> new UsernameNotFoundException("회원을 찾을 수 없습니다.")); + return new CustomUserDetails(user, null); + } +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/DelegatedAccessDeniedHandler.java b/src/main/java/com/example/gamemate/global/config/auth/DelegatedAccessDeniedHandler.java new file mode 100644 index 0000000..7f3656f --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/DelegatedAccessDeniedHandler.java @@ -0,0 +1,29 @@ +package com.example.gamemate.global.config.auth; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; + +@Component +public class DelegatedAccessDeniedHandler implements AccessDeniedHandler { + + private final HandlerExceptionResolver resolver; + + public DelegatedAccessDeniedHandler( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + resolver.resolveException(request, response, null, accessDeniedException); + } +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/DelegatedAuthenticationEntryPoint.java b/src/main/java/com/example/gamemate/global/config/auth/DelegatedAuthenticationEntryPoint.java new file mode 100644 index 0000000..ce9df55 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/DelegatedAuthenticationEntryPoint.java @@ -0,0 +1,29 @@ +package com.example.gamemate.global.config.auth; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; + +@Component +public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final HandlerExceptionResolver resolver; + + public DelegatedAuthenticationEntryPoint( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + resolver.resolveException(request, response, null, authException); + } +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/MailConfig.java b/src/main/java/com/example/gamemate/global/config/auth/MailConfig.java new file mode 100644 index 0000000..9ea465c --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/MailConfig.java @@ -0,0 +1,41 @@ +package com.example.gamemate.global.config.auth; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class MailConfig { + @Value("${spring.mail.host}") + private String mailHost; + + @Value("${spring.mail.port}") + private int mailPort; + + @Value("${spring.mail.username}") + private String mailUsername; + + @Value("${spring.mail.password}") + private String mailPassword; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(mailHost); + mailSender.setPort(mailPort); + mailSender.setUsername(mailUsername); + mailSender.setPassword(mailPassword); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.debug", "true"); + + return mailSender; + } +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/OAuth2FailureHandler.java b/src/main/java/com/example/gamemate/global/config/auth/OAuth2FailureHandler.java new file mode 100644 index 0000000..ad271e0 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/OAuth2FailureHandler.java @@ -0,0 +1,43 @@ +package com.example.gamemate.global.config.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${oauth2.failure.redirect-uri}") + private String redirectUri; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException { + + // 오류 메세지 가져오기 + String errorMessage = exception.getMessage(); + String encodedError = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); + + // 실패 리다이렉트 URL 생성 + String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("error", encodedError) + .build().toUriString(); + + // 리다이렉트 + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/com/example/gamemate/global/config/auth/OAuth2SuccessHandler.java b/src/main/java/com/example/gamemate/global/config/auth/OAuth2SuccessHandler.java new file mode 100644 index 0000000..6982669 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/config/auth/OAuth2SuccessHandler.java @@ -0,0 +1,62 @@ +package com.example.gamemate.global.config.auth; + +import com.example.gamemate.domain.auth.dto.LoginTokenResponseDto; +import com.example.gamemate.domain.auth.service.TokenService; +import com.example.gamemate.domain.user.entity.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final TokenService tokenService; + + @Value("${oauth2.success.redirect-uri}") + private String successRedirectUri; + + @Value("${oauth2.set-password.redirect-uri}") + private String passwordSetupRedirectUri; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + log.info("OAuth2 로그인 성공 처리 시작"); + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + User user = userDetails.getUser(); + + try { + // 토큰 생성 및 저장 + LoginTokenResponseDto tokenDto = tokenService.generateLoginTokens(user, response); + + String targetUrl; + if("OAUTH2_USER".equals(user.getPassword())) { + targetUrl = UriComponentsBuilder.fromUriString(passwordSetupRedirectUri) + .queryParam("token", tokenDto.getAccessToken()) + .build(false).toUriString(); + } else { + targetUrl = UriComponentsBuilder.fromUriString(successRedirectUri) + .queryParam("token", tokenDto.getAccessToken()) + .build(false).toUriString(); + } + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + + } catch (Exception e) { + throw new IOException("OAuth2 인증 처리 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/com/example/gamemate/global/constant/ErrorCode.java b/src/main/java/com/example/gamemate/global/constant/ErrorCode.java new file mode 100644 index 0000000..60dc585 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/constant/ErrorCode.java @@ -0,0 +1,71 @@ +package com.example.gamemate.global.constant; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + /* 400 잘못된 입력값 */ + INVALID_INPUT(HttpStatus.BAD_REQUEST, "INVALID_INPUT", "잘못된 요청입니다."), + INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "INVALID_PARAMETER", "잘못된 요청입니다."), + IS_ALREADY_FOLLOWED(HttpStatus.BAD_REQUEST, "IS_ALREADY_FOLLOWED", "이미 팔로우 한 회원입니다."), + IS_WITHDRAWN_USER(HttpStatus.BAD_REQUEST, "IS_WITHDRAW_USER", "비활성화된 회원입니다."), + DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "DUPLICATE_USER", "이미 사용 중인 이메일입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "INVALID_PASSWORD", "비밀번호가 일치하지 않습니다."), + REVIEW_ALREADY_EXISTS(HttpStatus.BAD_REQUEST,"REVIEW_ALREADY_EXISTS","이미 리뷰를 작성한 회원입니다."), + IS_ALREADY_PENDING(HttpStatus.BAD_REQUEST, "IS_ALREADY_PENDING", "이미 대기중인 요청이 있습니다."), + IS_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "IS_ALREADY_PROCESSED", "이미 처리된 요청입니다."), + IS_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "IS_ALREADY_EXIST", "존재하는 정보가 있습니다."), + FILE_UPLOAD_ERROR(HttpStatus.BAD_REQUEST,"FILE_UPLOAD_ERROR","파일 업로드 중 오류가 발생했습니다."), + INVALID_PROVIDER_TYPE(HttpStatus.BAD_REQUEST,"INVALID_PROVIDER_TYPE", "지원하지 않는 서비스 제공자입니다."), + INVALID_OAUTH2_ATTRIBUTE(HttpStatus.BAD_REQUEST, "INVALID_OAUTH2_ATTRIBUTE", "인증 정보가 유효하지 않습니다."), + VERIFICATION_TIME_EXPIRED(HttpStatus.BAD_REQUEST, "VERIFICATION_TIME_EXPIRED", "인증 시간이 만료되었습니다."), + VERIFICATION_NOT_FOUND(HttpStatus.BAD_REQUEST, "VERIFICATION_NOT_FOUND", "인증 정보를 찾을 수 없습니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "INVALID_VERIFICATION_CODE", "인증 코드가 일치하지 않습니다."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "EMAIL_NOT_VERIFIED", "이메일 인증이 필요합니다"), + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST,"INVALID_FILE_EXTENSION", "허용되지 않는 파일 형식입니다."), + FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST,"FILE_SIZE_EXCEEDED","파일 크기가 초과되었습니다."), + SOCIAL_PASSWORD_ALREADY_SET(HttpStatus.BAD_REQUEST, "SOCIAL_PASSWORD_ALREADY_SET", "이미 비밀번호가 설정되었습니다."), + SOCIAL_PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "SOCIAL_PASSWORD_REQUIRED", "소셜 로그인 계정은 비밀번호 설정이 필요합니다."), + COUPON_CODE_DUPLICATED(HttpStatus.BAD_REQUEST, "COUPON_CODE_DUPLICATED", "이미 존재하는 쿠폰 코드입니다."), + INVALID_COUPON_DATE(HttpStatus.BAD_REQUEST, "INVALID_COUPON_DATE", "쿠폰의 사용 기간이 올바르지 않습니다."), + COUPON_EXHAUSTED(HttpStatus.BAD_REQUEST, "COUPON_EXHAUSTED", "쿠폰이 모두 소진되었습니다."), + COUPON_ALREADY_ISSUED(HttpStatus.BAD_REQUEST, "COUPON_ALREADY_ISSUED", "이미 발급된 쿠폰입니다."), + COUPON_NOT_ISSUABLE(HttpStatus.BAD_REQUEST, "COUPON_NOT_ISSUABLE", "쿠폰 발급 기간이 아닙니다."), + COUPON_ALREADY_USED(HttpStatus.BAD_REQUEST, "COUPON_ALREADY_USED", "이미 사용된 쿠폰입니다."), + COUPON_EXPIRED(HttpStatus.BAD_REQUEST, "COUPON_EXPIRED", "쿠폰의 유효 기간이 만료되었습니다."), + + /* 401 인증 오류 */ + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "로그인이 필요합니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "유효하지 않은 토큰입니다."), + + /* 403 권한 없음 */ + FORBIDDEN(HttpStatus.FORBIDDEN, "FORBIDDEN", "권한이 없습니다."), + SOCIAL_PASSWORD_FORBIDDEN(HttpStatus.FORBIDDEN, "SOCIAL_PASSWORD_FORBIDDEN", "소셜 로그인 계정의 비밀번호를 설정할 수 없습니다."), + + /* 404 찾을 수 없음 */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "회원을 찾을 수 없습니다."), + FOLLOW_NOT_FOUND(HttpStatus.NOT_FOUND,"FOLLOW_NOT_FOUND", "팔로우를 찾을 수 없습니다."), + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "BOARD_NOT_FOUND", "게시글을 찾을 수 없습니다."), + GAME_NOT_FOUND(HttpStatus.NOT_FOUND,"GAME_NOT_FOUND","게임을 찾을 수 없습니다."), + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND,"REVIEW_NOT_FOUND","리뷰를 찾을 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "댓글을 찾을 수 없습니다."), + MATCH_NOT_FOUND(HttpStatus.NOT_FOUND, "MATCH_NOT_FOUND", "매칭을 찾을 수 없습니다."), + BOARD_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "BOARD_IMAGE_NOT_FOUND", "이미지를 찾을 수 없습니다."), + RECOMMENDATION_NOT_FOUND(HttpStatus.NOT_FOUND,"RECOMMENDATION_NOT_FOUND","추천 게임을 찾을 수 없습니다."), + MATCH_USER_INFO_NOT_FOUND(HttpStatus.NOT_FOUND, "MATCH_USER_INFO_NOT_FOUND", "매칭을 위해 입력된 회원 정보를 찾을 수 없습니다."), + MATCH_USER_INFO_NOT_WRITTEN(HttpStatus.NOT_FOUND, "MATCH_USER_INFO_NOT_WRITTEN", "매칭을 위해 회원 정보 입력은 필수입니다."), + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_NOT_FOUND", "알림을 찾을수 없습니다."), + COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "COUPON_NOT_FOUND", "쿠폰을 찾을 수 없습니다."), + + /* 500 서버 오류 */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"INTERNAL_SERVER_ERROR","서버 오류 입니다."), + EMAIL_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_SEND_ERROR", "이메일 전송에 문제가 발생했습니다."), + COUPON_ISSUE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "COUPON_ISSUE_FAILED", "쿠폰 발급에 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/gamemate/global/eventListener/GlobalEventListener.java b/src/main/java/com/example/gamemate/global/eventListener/GlobalEventListener.java new file mode 100644 index 0000000..9d8171e --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/GlobalEventListener.java @@ -0,0 +1,121 @@ +package com.example.gamemate.global.eventListener; + +import com.example.gamemate.domain.comment.entity.Comment; +import com.example.gamemate.domain.follow.entity.Follow; +import com.example.gamemate.domain.like.entity.BoardLike; +import com.example.gamemate.domain.like.entity.ReviewLike; +import com.example.gamemate.domain.match.entity.Match; +import com.example.gamemate.domain.notification.entity.Notification; +import com.example.gamemate.domain.notification.enums.NotificationType; +import com.example.gamemate.domain.notification.service.NotificationService; +import com.example.gamemate.domain.reply.entity.Reply; +import com.example.gamemate.global.eventListener.event.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Component +@RequiredArgsConstructor +@Slf4j +public class GlobalEventListener { + + private final NotificationService notificationService; + + @Async + @EventListener + public void handleCreateFollow(FollowCreatedEvent event) { + log.info("새로운 팔로우 알림 전송 시작"); + Follow follow = event.getFollow(); + + Notification notification = notificationService.createNotification(follow.getFollowee(), NotificationType.NEW_FOLLOWER, "/users/" + follow.getFollower().getId()); + notificationService.sendNotification(notification); + } + + @Async + @EventListener + public void handleCreateMatch(MatchCreatedEvent event) { + log.info("새로운 매칭 알림 전송 시작"); + Match match = event.getMatch(); + + Notification notification = notificationService.createNotification(match.getReceiver(), NotificationType.NEW_MATCH, "/matches/" + match.getId()); + notificationService.sendNotification(notification); + } + + @Async + @EventListener + public void handleAcceptMatch(MatchAcceptedEvent event) { + log.info("매칭 수락 알림 전송 시작"); + Match match = event.getMatch(); + + Notification notification = notificationService.createNotification(match.getSender(), NotificationType.MATCH_ACCEPTED, "/matches/" + match.getId()); + notificationService.sendNotification(notification); + } + + @Async + @EventListener + public void handleRejectMatch(MatchRejectedEvent event) { + log.info("매칭 거절 알림 전송 시작"); + Match match = event.getMatch(); + + Notification notification = notificationService.createNotification(match.getSender(), NotificationType.MATCH_REJECTED, "/matches/" + match.getId()); + notificationService.sendNotification(notification); + } + + @Async + @EventListener + public void handleCreateBoardLike(BoardLikeCreatedEvent event) { + log.info("게시글 새로운 좋아요 알림 전송 시작"); + BoardLike boardLike = event.getBoardLike(); + + Notification notification = notificationService.createNotification(boardLike.getBoard().getUser(), NotificationType.NEW_LIKE, "/boards/" + boardLike.getBoard().getId()); + notificationService.sendNotification(notification); + } + + @Async + @EventListener + public void handleCreateReviewLike(ReviewLikeCreatedEvent event) { + log.info("리뷰 새로운 좋아요 알림 전송 시작"); + ReviewLike reviewLike = event.getReviewLike(); + + Notification notification = notificationService.createNotification(reviewLike.getReview().getUser(), NotificationType.NEW_LIKE, "/reviews/" + reviewLike.getReview().getId()); + notificationService.sendNotification(notification); + } + + @Async + @EventListener + public void handleCreateComment(CommentCreatedEvent event) { + log.info("새로운 댓글 알림 전송 시작"); + Comment comment = event.getComment(); + + if (!Objects.equals(comment.getUser().getId(), comment.getBoard().getUser().getId())) { + Notification notification = notificationService.createNotification(comment.getBoard().getUser(), NotificationType.NEW_COMMENT, "/comments/" + comment.getId()); + notificationService.sendNotification(notification); + } + } + + @Async + @EventListener + public void handleCreateReply(ReplyCreatedEvent event) { + log.info("새로운 대댓글 알림 전송 시작"); + Reply reply = event.getReply(); + + if (!Objects.equals(reply.getUser().getId(), reply.getComment().getBoard().getUser().getId())) { + Notification boardNotification = notificationService.createNotification(reply.getComment().getBoard().getUser(), NotificationType.NEW_COMMENT, "/replies/" + reply.getId()); + notificationService.sendNotification(boardNotification); + } + + if (!Objects.equals(reply.getUser().getId(), reply.getComment().getUser().getId())) { + Notification commentNotification = notificationService.createNotification(reply.getComment().getUser(), NotificationType.NEW_COMMENT, "/replies/" + reply.getId()); + notificationService.sendNotification(commentNotification); + } + + if (reply.getParentReply() != null && !Objects.equals(reply.getParentReply().getUser().getId(), reply.getUser().getId())) { + Notification parentReplyNotification = notificationService.createNotification(reply.getParentReply().getUser(), NotificationType.NEW_COMMENT, "/replies/" + reply.getId()); + notificationService.sendNotification(parentReplyNotification); + } + } +} diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/BoardLikeCreatedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/BoardLikeCreatedEvent.java new file mode 100644 index 0000000..98c9015 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/BoardLikeCreatedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.like.entity.BoardLike; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class BoardLikeCreatedEvent extends ApplicationEvent { + private final BoardLike boardLike; + + public BoardLikeCreatedEvent(Object source, BoardLike boardLike) { + super(source); + this.boardLike = boardLike; + } +} diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/CommentCreatedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/CommentCreatedEvent.java new file mode 100644 index 0000000..2044f7c --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/CommentCreatedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.comment.entity.Comment; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class CommentCreatedEvent extends ApplicationEvent { + private final Comment comment; + + public CommentCreatedEvent(Object source, Comment comment) { + super(source); + this.comment = comment; + } +} diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/FollowCreatedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/FollowCreatedEvent.java new file mode 100644 index 0000000..3bbb19b --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/FollowCreatedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.follow.entity.Follow; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class FollowCreatedEvent extends ApplicationEvent { + private final Follow follow; + + public FollowCreatedEvent(Object source, Follow follow) { + super(source); + this.follow = follow; + } +} diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/MatchAcceptedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/MatchAcceptedEvent.java new file mode 100644 index 0000000..a99b258 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/MatchAcceptedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.match.entity.Match; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class MatchAcceptedEvent extends ApplicationEvent { + private final Match match; + + public MatchAcceptedEvent(Object source, Match match) { + super(source); + this.match = match; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/MatchCreatedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/MatchCreatedEvent.java new file mode 100644 index 0000000..7d7410c --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/MatchCreatedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.match.entity.Match; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class MatchCreatedEvent extends ApplicationEvent { + private final Match match; + + public MatchCreatedEvent(Object source, Match match) { + super(source); + this.match = match; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/MatchRejectedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/MatchRejectedEvent.java new file mode 100644 index 0000000..6b62249 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/MatchRejectedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.match.entity.Match; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class MatchRejectedEvent extends ApplicationEvent { + private final Match match; + + public MatchRejectedEvent(Object source, Match match) { + super(source); + this.match = match; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/ReplyCreatedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/ReplyCreatedEvent.java new file mode 100644 index 0000000..ae86ad6 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/ReplyCreatedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.reply.entity.Reply; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class ReplyCreatedEvent extends ApplicationEvent { + private final Reply reply; + + public ReplyCreatedEvent(Object source, Reply reply) { + super(source); + this.reply = reply; + } +} diff --git a/src/main/java/com/example/gamemate/global/eventListener/event/ReviewLikeCreatedEvent.java b/src/main/java/com/example/gamemate/global/eventListener/event/ReviewLikeCreatedEvent.java new file mode 100644 index 0000000..194d16c --- /dev/null +++ b/src/main/java/com/example/gamemate/global/eventListener/event/ReviewLikeCreatedEvent.java @@ -0,0 +1,15 @@ +package com.example.gamemate.global.eventListener.event; + +import com.example.gamemate.domain.like.entity.ReviewLike; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class ReviewLikeCreatedEvent extends ApplicationEvent { + private final ReviewLike reviewLike; + + public ReviewLikeCreatedEvent(Object source, ReviewLike reviewLike) { + super(source); + this.reviewLike = reviewLike; + } +} diff --git a/src/main/java/com/example/gamemate/global/exception/ApiException.java b/src/main/java/com/example/gamemate/global/exception/ApiException.java new file mode 100644 index 0000000..d0085f8 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/exception/ApiException.java @@ -0,0 +1,11 @@ +package com.example.gamemate.global.exception; + +import com.example.gamemate.global.constant.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ApiException extends RuntimeException { + private final ErrorCode errorCode; +} diff --git a/src/main/java/com/example/gamemate/global/exception/ErrorResponse.java b/src/main/java/com/example/gamemate/global/exception/ErrorResponse.java new file mode 100644 index 0000000..b15ba16 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/exception/ErrorResponse.java @@ -0,0 +1,46 @@ +package com.example.gamemate.global.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.bind.validation.ValidationErrors; +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class ErrorResponse { + private final int status; + private final String code; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List errors; + + @Getter + @Builder + public static class ValidationError{ + private final String field; + private final String message; + + public static ValidationError of(final FieldError fieldError) { + return ValidationError.builder() + .field(fieldError.getField()) + .message(fieldError.getDefaultMessage()) + .build(); + } + + public static List of(final List fieldErrors) { + return fieldErrors.stream() + .map(ValidationError::of) + .collect(Collectors.toList()); + } + } + + + +} diff --git a/src/main/java/com/example/gamemate/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/gamemate/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..dc3244c --- /dev/null +++ b/src/main/java/com/example/gamemate/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,155 @@ +package com.example.gamemate.global.exception; + +import com.example.gamemate.global.constant.ErrorCode; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.server.ResponseStatusException; + +import javax.security.sasl.AuthenticationException; +import java.security.SignatureException; +import java.util.List; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + @ExceptionHandler(ApiException.class) + public ResponseEntity handleCustomException(ApiException e) { + log.info("errorHandler start"); + ErrorCode errorCode = e.getErrorCode(); + return handleExceptionInternal(errorCode,errorCode.getMessage()); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException(ResponseStatusException ex) { + + return ResponseEntity.status(ex.getStatusCode()) + .body(ex.getReason()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + log.warn("handleIllegalArgument", e); + ErrorCode errorCode; + if("유효하지 않은 토큰입니다.".equals(e.getMessage())) { + errorCode = ErrorCode.INVALID_TOKEN; + } else { + errorCode = ErrorCode.INVALID_PARAMETER; + } + return handleExceptionInternal(errorCode, errorCode.getMessage()); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleIRuntime(RuntimeException e) { + log.warn("handleIRuntime", e); + ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + return handleExceptionInternal(errorCode, errorCode.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e) { + log.warn("handleMethodArgumentNotValid", e); + + // 유효성 검사 에러 리스트 변환 + List errors = e.getBindingResult().getFieldErrors().stream() + .map(ErrorResponse.ValidationError::of) + .collect(Collectors.toList()); + + // ErrorResponse + ErrorResponse errorResponse = ErrorResponse.builder() + .status(ErrorCode.INVALID_PARAMETER.getStatus().value()) + .code(ErrorCode.INVALID_INPUT.name()) + .message("Validation failed") + .errors(errors) + .build(); + + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(InsufficientAuthenticationException.class) + public ResponseEntity handleInsufficientAuthenticationException(InsufficientAuthenticationException e) { + log.warn("Authentication exception", e); + ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + return handleExceptionInternal(errorCode, errorCode.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException e) { + log.warn("Authentication exception", e); + ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + return handleExceptionInternal(errorCode, errorCode.getMessage()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException(AccessDeniedException e) { + log.warn("handleAccessDeniedException", e); + ErrorCode errorCode = ErrorCode.FORBIDDEN; + return handleExceptionInternal(errorCode, errorCode.getMessage()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.warn("handleHttpMessageNotReadableException", e); + ErrorCode errorCode = ErrorCode.INVALID_INPUT; + return handleExceptionInternal(errorCode); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException e) { + log.warn("handleDataIntegrityViolationException", e); + ErrorCode errorCode = ErrorCode.IS_ALREADY_EXIST; + return handleExceptionInternal(errorCode); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("Unhandled exception occurred", e); + ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + return handleExceptionInternal(errorCode); + } + + + private ResponseEntity handleExceptionInternal(ErrorCode errorCode) { + return ResponseEntity.status(errorCode.getStatus()) + .body(makeErrorResponse(errorCode)); + } + + private ErrorResponse makeErrorResponse(ErrorCode errorCode) { + return ErrorResponse.builder() + .status(errorCode.getStatus().value()) + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + } + + private ResponseEntity handleExceptionInternal(ErrorCode errorCode, String message) { + return ResponseEntity.status(errorCode.getStatus()) + .body(makeErrorResponse(errorCode, message)); + } + + private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) { + return ErrorResponse.builder() + .status(errorCode.getStatus().value()) + .code(errorCode.name()) + .message(message) + .build(); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxSizeException(MaxUploadSizeExceededException exc) { + log.warn("File size limit exceeded", exc); + ErrorCode errorCode = ErrorCode.FILE_SIZE_EXCEEDED; + return handleExceptionInternal(errorCode); + } + +} diff --git a/src/main/java/com/example/gamemate/global/filter/JwtAuthenticationFilter.java b/src/main/java/com/example/gamemate/global/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..14e821a --- /dev/null +++ b/src/main/java/com/example/gamemate/global/filter/JwtAuthenticationFilter.java @@ -0,0 +1,85 @@ +package com.example.gamemate.global.filter; + +import ch.qos.logback.core.util.StringUtil; +import com.example.gamemate.domain.auth.service.TokenService; +import com.example.gamemate.global.config.auth.CustomUserDetails; +import com.example.gamemate.global.provider.JwtTokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsService userDetailsService; + private final TokenService tokenService; + + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getServletPath(); + return path.startsWith("/auth/login") || + path.startsWith("/auth/signup") || + path.startsWith("/swagger-ui") || + path.startsWith("/v3/api-docs"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // Authorization 헤더에서 JWT 토큰 추출 + String token = extractToken(request); + + // 토큰이 유효한 경우에만 인증 처리 + if (token != null && tokenService.validateToken(token)) { + String email = jwtTokenProvider.getEmailFromToken(token); + Authentication authentication = createAuthentication(email); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private Authentication createAuthentication(String email) { + CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } + + private String extractToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + +} diff --git a/src/main/java/com/example/gamemate/global/provider/JwtTokenProvider.java b/src/main/java/com/example/gamemate/global/provider/JwtTokenProvider.java new file mode 100644 index 0000000..267cf9e --- /dev/null +++ b/src/main/java/com/example/gamemate/global/provider/JwtTokenProvider.java @@ -0,0 +1,91 @@ +package com.example.gamemate.global.provider; + +import com.example.gamemate.domain.user.enums.Role; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + @Value("${spring.jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token.expiration:3600000}") + private int accessTokenExpirationMs; //60분 + + @Value("${jwt.refresh-token.expiration:604800000}") + private int refreshTokenExpirationMs; //7일 + + public String createAccessToken(String email, Role role) { + Claims claims = Jwts.claims().setSubject(email); + claims.put("role", role.getName()); + + Date now = new Date(); + Date validity = new Date(now.getTime() + accessTokenExpirationMs); + Key signingKey = generateSigningKey(); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(signingKey) + .compact(); + } + + public String createRefreshToken(String email) { + Claims claims = Jwts.claims().setSubject(email); + + Date now = new Date(); + Date validity = new Date(now.getTime() + refreshTokenExpirationMs); + Key signingKey = generateSigningKey(); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(signingKey) + .compact(); + } + + public boolean validateToken(String token) { + try{ + getTokenClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 토큰입니다.", e); + } + } + + public String getEmailFromToken(String token) { + return getTokenClaims(token).getSubject(); + } + + public long getExpirationFromToken(String token) { + return getTokenClaims(token).getExpiration().getTime(); + } + + private Key generateSigningKey() { + return new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName()); + } + + + private Claims getTokenClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(generateSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + +} diff --git a/src/main/java/com/example/gamemate/global/s3/S3Service.java b/src/main/java/com/example/gamemate/global/s3/S3Service.java new file mode 100644 index 0000000..f00a4f0 --- /dev/null +++ b/src/main/java/com/example/gamemate/global/s3/S3Service.java @@ -0,0 +1,54 @@ +package com.example.gamemate.global.s3; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.beans.factory.annotation.Value; + +import java.io.IOException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class S3Service { + private final AmazonS3Client amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public String uploadFile(MultipartFile file) throws IOException { + String fileName = createFileName(file.getOriginalFilename()); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + // ACL 설정 제거하고 기본 putObject 사용 + amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, + file.getInputStream(), metadata)); + + return String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, amazonS3Client.getRegionName() ,fileName); + } + + private String createFileName(String originalFileName) { + return UUID.randomUUID().toString() + "-" + originalFileName; + } + + public void deleteFile(String fileUrl) { + try { + // URL에서 파일 키(경로) 추출 + String fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1); + + // S3에서 파일 삭제 + amazonS3Client.deleteObject(bucket, fileName); + } catch (Exception e) { + log.error("파일 삭제 중 오류 발생: {}", e.getMessage()); + throw new RuntimeException("파일 삭제 실패"); + } + + } +} diff --git a/src/main/java/com/example/gamemate/global/test/TestDataController.java b/src/main/java/com/example/gamemate/global/test/TestDataController.java new file mode 100644 index 0000000..178982c --- /dev/null +++ b/src/main/java/com/example/gamemate/global/test/TestDataController.java @@ -0,0 +1,58 @@ +package com.example.gamemate.global.test; + +import com.example.gamemate.domain.follow.entity.Follow; +import com.example.gamemate.domain.follow.repository.FollowRepository; +import com.example.gamemate.domain.user.entity.User; +import com.example.gamemate.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/test") +public class TestDataController { + private final UserRepository userRepository; + private final FollowRepository followRepository; + + @PostMapping("/init") + public ResponseEntity initializeTestData() { + // 타겟 유저 생성 + User targetUser = new User("target@test.com", "TargetUser", "TargetUser", "1234"); + userRepository.save(targetUser); + + // 1000명의 팔로워 생성 및 팔로우 관계 설정 + List followers = new ArrayList<>(); + List follows = new ArrayList<>(); + + for (int i = 0; i < 1000; i++) { + User follower = new User( + "test" + i + "@test.com", + "User" + i, + "User" + i, + "1234" + ); + followers.add(follower); + } + + // 벌크 저장으로 성능 향상 + List savedFollowers = userRepository.saveAll(followers); + + // 팔로우 관계 생성 + for (User follower : savedFollowers) { + follows.add(new Follow(follower, targetUser)); + } + followRepository.saveAll(follows); + + return ResponseEntity.ok(String.format( + "테스트 데이터 생성 완료 (타겟 유저: %s, 팔로워: %d명)", + targetUser.getEmail(), + follows.size() + )); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..28d1453 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,7 @@ +spring.datasource.url=${MYSQL_DEV_URL} +spring.datasource.username=${MYSQL_USERNAME} +spring.datasource.password=${MYSQL_PASSWORD} +spring.jpa.hibernate.ddl-auto=${JPA_HIBERNATE_DDL_DEV} +spring.jpa.defer-datasource-initialization=true +spring.sql.init.mode=always +spring.sql.init.data-locations=classpath:data-dev.sql \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..c73285f --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,6 @@ +spring.datasource.url=${MYSQL_PROD_URL} +spring.datasource.username=${MYSQL_USERNAME} +spring.datasource.password=${MYSQL_PASSWORD} +spring.jpa.hibernate.ddl-auto=${JPA_HIBERNATE_DDL_PROD} +spring.jpa.show-sql=false +spring.sql.init.mode=never \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6ccbb84..7611fd8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,17 +1,96 @@ spring.application.name=gamemate + spring.config.import=optional:file:.env[.properties] +# Environment (prod, dev) +spring.profiles.active=dev + spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.datasource.url=${MYSQL_URL} -spring.datasource.username=${MYSQL_USERNAME} -spring.datasource.password=${MYSQL_PASSWORD} spring.jpa.show-sql=true spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect # create, update, none, creat-drop spring.jpa.database=mysql -spring.jpa.hibernate.ddl-auto=${JPA_HIBERNATE_DDL} spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy spring.jpa.generate-ddl=false spring.jpa.properties.hibernate.format_sql=true -spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true \ No newline at end of file +spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true + +spring.jwt.secret=${JWT_SECRET} + +# Google OAuth2 +spring.security.oauth2.client.registration.google.client-id=${OAUTH2_GOOGLE_CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${OAUTH2_GOOGLE_CLIENT_SECRET} +spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google +spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.google.scope=profile,email +spring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth +spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token +spring.security.oauth2.client.provider.google.user-info-uri=https://openidconnect.googleapis.com/v1/userinfo +spring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs +spring.security.oauth2.client.provider.google.user-name-attribute=sub + +# Kakao OAuth2 +spring.security.oauth2.client.registration.kakao.client-id=${OAUTH2_KAKAO_CLIENT_ID} +spring.security.oauth2.client.registration.kakao.client-secret=${OAUTH2_KAKAO_CLIENT_SECRET} +spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao +spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email +spring.security.oauth2.client.registration.kakao.client-name=Kakao +spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post + +# Kakao Provider +spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token +spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me +spring.security.oauth2.client.provider.kakao.user-name-attribute=id + +#oauth2.redirect.uri=http://localhost:3000/oauth2/callback +oauth2.success.redirect-uri=http://localhost:8080/oauth2-login-success.html +oauth2.failure.redirect-uri=http://localhost:8080/oauth2-login-failure.html +oauth2.set-password.redirect-uri=http://localhost:8080/oauth2-set-password.html + +# EMAIL +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${EMAIL_USERNAME} +spring.mail.password=${EMAIL_APP_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.starttls.enable=true + +# DEBUG +logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.security.oauth2=TRACE +logging.level.org.springframework.web=DEBUG +logging.level.org.springframework.web.servlet=DEBUG +logging.level.org.springframework.web.client=INFO +logging.level.com.example.gamemate=DEBUG +logging.level.org.springframework.data.redis=INFO +logging.level.com.example.gamemate.domain.notification.service.RedisStreamService=INFO + +# Gemini +gemini.api.url=${GEMINI_URL} +gemini.api.key=${GEMINI_KEY} + +#S3 +cloud.aws.credentials.access-key=${AWS_ACCESS_KEY} +cloud.aws.credentials.secret-key=${AWS_SECRET_KEY} +cloud.aws.s3.bucket=${AWS_BUCKET} +cloud.aws.region.static=${AWS_REGION} +cloud.aws.stack.auto=${AWS_STACK_AUTO} + +#multipart +spring.servlet.multipart.max-file-size=5MB +spring.servlet.multipart.max-request-size=5MB + +# Lock timeout +spring.jpa.properties.jakarta.persistence.lock.timeout=3000 + +# Redis +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=6379 + +# Jwt Token +jwt.access-token.expiration=3600000 +jwt.refresh-token.expiration=604800000 diff --git a/src/main/resources/data-dev.sql b/src/main/resources/data-dev.sql new file mode 100644 index 0000000..e4cfa22 --- /dev/null +++ b/src/main/resources/data-dev.sql @@ -0,0 +1,81 @@ +-- User 테이블 데이터 +INSERT INTO `user` ( + email, + name, + nickname, + password, + role, + is_premium, + user_status, + provider, + created_at, + modified_at +) VALUES + ('user1@test.com', '유저1', '유저닉1', '$2a$10$HuoYrGAAXUn2LJ3EA8ZTJubGdkLyvlYxbs4cQjG0WYQ0BNDa5Xi2S', 'USER', false, 'ACTIVE', 'LOCAL', now(), now()), + ('user2@test.com', '유저2', '유저닉2', '$2a$10$HuoYrGAAXUn2LJ3EA8ZTJubGdkLyvlYxbs4cQjG0WYQ0BNDa5Xi2S', 'USER', true, 'ACTIVE', 'LOCAL', now(), now()), + ('user3@test.com', '유저3', '유저닉3', '$2a$10$HuoYrGAAXUn2LJ3EA8ZTJubGdkLyvlYxbs4cQjG0WYQ0BNDa5Xi2S', 'USER', false, 'ACTIVE', 'LOCAL', now(), now()), + ('user4@test.com', '유저4', '유저닉4', '$2a$10$HuoYrGAAXUn2LJ3EA8ZTJubGdkLyvlYxbs4cQjG0WYQ0BNDa5Xi2S', 'USER', true, 'ACTIVE', 'LOCAL', now(), now()), + ('admin1@test.com', '관리자1', '관리자닉1', '$2a$10$HuoYrGAAXUn2LJ3EA8ZTJubGdkLyvlYxbs4cQjG0WYQ0BNDa5Xi2S', 'ADMIN', true, 'ACTIVE', 'LOCAL', now(), now()), + ('admin2@test.com', '관리자2', '관리자닉2', '$2a$10$HuoYrGAAXUn2LJ3EA8ZTJubGdkLyvlYxbs4cQjG0WYQ0BNDa5Xi2S', 'ADMIN', false, 'ACTIVE', 'LOCAL', now(), now()); + +-- MatchUserInfo 테이블 데이터 +INSERT INTO match_user_info ( + user_id, + gender, + game_rank, + skill_level, + mic_usage, + message, + created_at, + modified_at +) VALUES + (1, 'MALE', 'DIAMOND', 5, true, '같이 트롤하실분? 정글러 구함', now(), now()), + (2, 'FEMALE', 'BRONZE', 1, true, '그냥 즐겜할래요.', now(), now()), + (3, 'FEMALE', 'GOLD', 3, true, '랭겜 즐기실분 구합니다.', now(), now()), + (4, 'MALE', 'SILVER', 2, true, '초보 뉴비 환영합니다!', now(), now()), + (5, 'FEMALE', 'CHALLENGER', 6, false, '빡겜할 듀오 찾습니다.', now(), now()), + (6, 'MALE', 'PLATINUM', 4, true, '실버 이상만요!', now(), now()); + +-- user_lanes 테이블 데이터 +INSERT INTO user_lanes (match_user_info_id, lanes) +VALUES + (1, 'TOP'), (1, 'JUNGLE'), + (2, 'MID'), + (3, 'BOTTOM_AD'), (3, 'BOTTOM_SUPPORTER'), + (4, 'TOP'), (4, 'BOTTOM_SUPPORTER'), + (5, 'JUNGLE'), (5, 'MID'), + (6, 'TOP'), (6, 'JUNGLE'); + +-- user_purposes 테이블 데이터 +INSERT INTO user_purposes (match_user_info_id, purposes) +VALUES + (1, 'NORMAL_GAME'), (1, 'JUST_FOR_FUN'), (1, 'TEAMWORK'), + (2, 'RANK_GAME'), (2, 'DUO_PLAY'), + (3, 'MENTORING'), (3, 'BEGINNER_FRIENDLY'), + (4, 'JUST_FOR_FUN'), (4, 'TEAMWORK'), + (5, 'TRY_HARD'), (5, 'RANK_GAME'), + (6, 'NORMAL_GAME'); + +-- user_play_times 테이블 데이터 +INSERT INTO user_play_times (match_user_info_id, play_time_ranges) +VALUES + (1, 'ZERO_TO_SIX'), (1, 'EIGHTEEN_TO_TWENTY_FOUR'), + (2, 'SIX_TO_TWELVE'), + (3, 'TWELVE_TO_EIGHTEEN'), + (4, 'EIGHTEEN_TO_TWENTY_FOUR'), + (5, 'ZERO_TO_SIX'), + (6, 'SIX_TO_TWELVE'), (6, 'EIGHTEEN_TO_TWENTY_FOUR'); + +-- game 테이블 데이터 +INSERT INTO game (title, genre, platform, description, created_at, modified_at) +VALUES + ('라스트 오브 어스', 'Action', 'PlayStation', '종말 이후의 세계를 배경으로 한 스토리 중심의 서바이벌 게임.', now(), now()), + ('마인크래프트', 'Sandbox', 'PC', '무한히 생성되는 세계에서 블록을 쌓고 모험을 떠나는 게임.', now(), now()), + ('오버워치', 'Shooter', 'PC', '독특한 능력을 가진 다양한 영웅들이 등장하는 팀 기반 1인칭 슈팅 게임.', now(), now()), + ('스타듀 밸리', 'Simulation', 'PC', '할아버지의 오래된 농장을 물려받아 경영하는 농장 시뮬레이션 RPG.', now(), now()), + ('엘든 링', 'RPG', 'PC', '미야자키 히데타카와 조지 R.R. 마틴이 만든 다크 판타지 오픈 월드 액션 RPG.', now(), now()), + ('피파 23', 'Sports', 'PlayStation', '인기 축구 시뮬레이션 시리즈의 최신작으로, 업데이트된 팀과 향상된 게임플레이 제공.', now(), now()), + ('콜 오브 듀티: 워존', 'Shooter', 'PC', '콜 오브 듀티 세계관을 배경으로 한 무료 배틀로얄 게임.', now(), now()), + ('모여봐요 동물의 숲', 'Simulation', 'Nintendo Switch', '무인도에서 자신만의 낙원을 만들어가는 소셜 시뮬레이션 게임.', now(), now()), + ('포르자 호라이즌 5', 'Racing', 'Xbox', '멕시코의 생동감 넘치고 끊임없이 변화하는 풍경을 배경으로 한 오픈 월드 레이싱 게임.', now(), now()), + ('할로우 나이트', 'Adventure', 'PC', '광대하고 서로 연결된 세계를 배경으로 한 도전적인 2D 액션 어드벤처 게임.', now(), now()); \ No newline at end of file diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000..caeb40c Binary files /dev/null and b/src/main/resources/static/favicon.ico differ diff --git a/src/main/resources/static/oauth2-login-failure.html b/src/main/resources/static/oauth2-login-failure.html new file mode 100644 index 0000000..0060944 --- /dev/null +++ b/src/main/resources/static/oauth2-login-failure.html @@ -0,0 +1,23 @@ + + + + OAuth2 Login Failure + + + +

OAuth2 Login Failure

+

Error: Unknown error occurred

+ + \ No newline at end of file diff --git a/src/main/resources/static/oauth2-login-success.html b/src/main/resources/static/oauth2-login-success.html new file mode 100644 index 0000000..5d25cb3 --- /dev/null +++ b/src/main/resources/static/oauth2-login-success.html @@ -0,0 +1,11 @@ + + + + + OAuth2 Login Success + + +

OAuth2 Login Success!

+

테스트를 계속 진행하세요.

+ + \ No newline at end of file diff --git a/src/main/resources/static/oauth2-login.html b/src/main/resources/static/oauth2-login.html new file mode 100644 index 0000000..529b7b9 --- /dev/null +++ b/src/main/resources/static/oauth2-login.html @@ -0,0 +1,188 @@ + + + + + + + + GM 로그인 페이지 + + + +

Game Mate 로그인

+ + + + + +
+

로그인 성공!

+

환영합니다.

+ +
+ + + + \ No newline at end of file diff --git a/src/main/resources/static/oauth2-set-password.html b/src/main/resources/static/oauth2-set-password.html new file mode 100644 index 0000000..736820b --- /dev/null +++ b/src/main/resources/static/oauth2-set-password.html @@ -0,0 +1,171 @@ + + + + + + GM 비밀번호 설정 + + + +
+

비밀번호 설정

+
+
+ + +
+ 비밀번호는 8~20자의 영문, 숫자, 특수문자를 포함해야 합니다. +
+
+
+
+ + +
+
+ +
+
+ + + + \ No newline at end of file