diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..f29fa3f60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git/ +.gitignore/ +.idea/ diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md new file mode 100644 index 000000000..1172ee039 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -0,0 +1,20 @@ +--- +name: "\bIssue 생성 템플릿" +about: 해당 Issue 생성 템플릿을 통하여 Issue를 생성해주세요. +title: 'Feat: Issue 제목' +labels: '' +assignees: '' + +--- + +### 📝 Description + +- 구현할 내용 1 +- 구현할 내용 2 + +--- + +### 📝 Todo + +- [ ] 구현할 내용 1 +- [ ] 구현할 내용 2 \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e509b97b3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ +## ✅ PR 유형 +어떤 변경 사항이 있었나요? + +- [ ] 새로운 기능 추가 +- [ ] 버그 수정 +- [ ] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경) +- [ ] 코드 리팩토링 +- [ ] 주석 추가 및 수정 +- [ ] 문서 수정 +- [ ] 빌드 부분 혹은 패키지 매니저 수정 +- [ ] 파일 혹은 폴더명 수정 +- [ ] 파일 혹은 폴더 삭제 + +--- + +## 📝 작업 내용 +이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) + +- 작업한 내용 1 +- 작업한 내용 2 + +--- + +## ✏️ 관련 이슈 +본인이 작업한 내용이 어떤 Issue Number와 관련이 있는지만 작성해주세요 + +ex) +- Fixes : #00 (수정중인 이슈) +- Resolves : #100 (무슨 이슈를 해결했는지) +- Ref : #00 #01 (참고할 이슈) +- Related to : #00 #01 (해당 커밋과 관려) + +--- + +## 🎸 기타 사항 or 추가 코멘트 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..8ff846f21 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,115 @@ +name: Cnergy Backend CI/CD + +on: + push: + branches: [dev] + +jobs: + ci: + runs-on: ubuntu-latest + + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up SSH key + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} + + - name: Download GeoLite2-City Database + run: | + mkdir -p src/main/resources/maxmind + + curl -L -u ${{ secrets.GEOIP_ACCOUNT_ID }}:${{ secrets.GEOIP_LICENSE }} \ + 'https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&suffix=tar.gz&license_key=${{ secrets.GEOIP_LICENSE }}' \ + -o GeoLite2-City.tar.gz + + tar -xzf GeoLite2-City.tar.gz --wildcards --strip-components 1 -C src/main/resources/maxmind '*.mmdb' + + rm GeoLite2-City.tar.gz + + - name: Gradle 캐시 적용 + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle + + - name: JDK 17 세팅 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: YML 파일 세팅 + env: + APPLICATION_PROPERTIES: ${{ secrets.APPLICATION_PROPERTIES }} + TEST_APPLICATION_PROPERTIES: ${{ secrets.TEST_APPLICATION_PROPERTIES }} + run: | + cd ./src + rm -rf main/resources/application.yml + mkdir -p test/resources + echo "$APPLICATION_PROPERTIES" > main/resources/application.yml + echo "$TEST_APPLICATION_PROPERTIES" > test/resources/application.yml + + - name: gradlew 권한 부여 + run: chmod +x gradlew + + - name: 테스트 수행 + run: ./gradlew test + + - name: 스프링부트 빌드 + run: ./gradlew build + + - name: Docker Buildx 세팅 + uses: docker/setup-buildx-action@v3 + + - name: NCP 레지스트리 로그인 + uses: docker/login-action@v3 + with: + registry: ${{ secrets.NCP_CONTAINER_REGISTRY }} + username: ${{ secrets.NCP_ACCESS_KEY }} + password: ${{ secrets.NCP_SECRET_KEY }} + + - name: 도커 이미지 빌드 후 푸시 + if: success() + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/cnergy-backend:${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + platforms: linux/amd64,linux/arm64 + + - name: Docker Compose 파일 NCP 서버로 전송 + run: scp -o StrictHostKeyChecking=no -P ${{ secrets.NCP_PORT }} docker-compose.yml ${{ secrets.NCP_USERNAME }}@${{ secrets.NCP_HOST }}:./ + + - name: NCP 접속 후 배포 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USERNAME }} + password: ${{ secrets.NCP_PASSWORD }} + port: ${{ secrets.NCP_PORT }} + script: | + echo "NCP_CONTAINER_REGISTRY=${{ secrets.NCP_CONTAINER_REGISTRY }}" > .env + echo "GITHUB_SHA=${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" >> .env + echo "RABBITMQ_DEFAULT_USER=${{ secrets.RABBITMQ_DEFAULT_USER }}" >> .env + echo "RABBITMQ_DEFAULT_PASS=${{ secrets.RABBITMQ_DEFAULT_PASS }}" >> .env + sudo chmod +x ./deploy.sh + ./deploy.sh diff --git a/.gitignore b/.gitignore index c2065bc26..e08eee973 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ out/ ### VS Code ### .vscode/ + +### Configuration ### +src/main/resources/application-*.yml +src/main/resources/maxmind +src/test/resources/application.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..9e4e82758 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:17.0.1-jdk-slim + +WORKDIR /app + +COPY ./build/libs/backend-0.0.1-SNAPSHOT.jar /app/backend.jar + +EXPOSE 8080 +ENTRYPOINT ["java"] +CMD ["-jar", "backend.jar"] diff --git a/Dockerfile-local b/Dockerfile-local new file mode 100644 index 000000000..455fa0f72 --- /dev/null +++ b/Dockerfile-local @@ -0,0 +1,17 @@ +FROM gradle:8.10.1-jdk17 AS build + +WORKDIR /app + +COPY . /app + +RUN gradle clean build -x test --no-daemon + +FROM openjdk:17.0.1-jdk-slim + +WORKDIR /app + +COPY --from=build /app/build/libs/*.jar /app/backend.jar + +EXPOSE 8080 +ENTRYPOINT ["java"] +CMD ["-jar", "backend.jar"] diff --git a/README.md b/README.md new file mode 100644 index 000000000..22e5be2b0 --- /dev/null +++ b/README.md @@ -0,0 +1,532 @@ +![intro](https://github.com/user-attachments/assets/5498e204-658e-40b8-9a3e-47b7f1bf643a) +# ⭐ 팀 소개 + +> **서비스명** : **조각조각** ⏰ + +‘조각조각’은 자투리 시간이라는 ’작은 조각‘들을 모아 하나의 큰 퍼즐인 ‘나만의 시간‘을 만들어낸다는 의미를 담았습니다. 특히, 시계를 표현하는 의성어인 ’째깍째깍‘과 유사한 초성을 이용하여, ’시간‘이라는 컨셉에 더욱 충실할 수 있도록 했습니다. + +> **팀명** : **C-nergy 시너지** 💛 + +C팀 + energy의 합성어로, C팀의 모든 팀원들이 각자의 에너지를 가지고 함께 함으로써 결국 최고의 시너지를 만들어가겠다는 의미를 담았습니다 😊 + + +> **R&R분배** + +| **분야** | **이름** | **포지션** | +| --- | --- | --- | +| ⚡️기획 | 한나영 | PM, 서비스 기획 - 데스크 리서치, 유저리서치, 아이데이션, 와이어프레임, 기능명세서, 화면명세서 | +| ⚡️기획 | 신예진 | 서비스 기획 - 데스크 리서치, 유저 리서치, 아이데이션, 와이어프레임, 페르소나 및 CJM, 화면명세서 | +| ⚡️기획 | 홍가연 | 서비스 기획 - 기획 리드, 데스크 리서치, 유저 리서치, 아이데이션, 와이어프레임, 기능명세서, 화면명세서 | +| 🎨디자인 | 최은정 | 프로덕트 디자인 - 브랜딩디자인, UXUI디자인, 그래픽디자인 | +| 💻프론트엔드 | 공예영 | UI 및 기능 구현, PR CI 구축 | +| 💻프론트엔드 | 황동준 | 프론트엔드 리드, UI 및 기능 구현, ncp 배포 및 CI/CD 구축 | +| 💻백엔드 | 전호영 | DB 및 API 구축, 서버 배포 ,네이버 클로바 추천 시스템 구현, 인프라 구축 | +| 💻백엔드 | 한성민 | 개발 리드, DB 설계 및 API 구현, OpenAI 추천 시스템 구현, 서버 배포 | + +# 🌟 서비스 개요 + +> **서비스 소개** + +⏰ ‘조각조각’은 **일상 속에서 발생하는 자투리 시간을 의미 있게 활용할 수 있도록 돕는 개인 맞춤형 활동 추천 서비스**입니다. + + +**`세부 기능`** + +- 사용자의 자투리 시간과 상황(온·오프라인 여부, 위치 등)을 기반으로 다양한 카테고리(자기개발/엔터테인먼크/휴식/문화예술/건강/소셜)별 활동 추천 리스트를 제공합니다. +- 사용자가 활용한 자투리 시간은 ‘시간 조각’으로 시각화해 아카이빙할 수 있으며, 자투리 시간을 온전한 ‘내 시간’으로 만들고 그 효능감을 인식할 수 있도록 돕습니다. + + +> **서비스 차별성** +> + +‘조각조각’은 일상 속에서 **불규칙하고 갑작스럽게 발생해 버려지기 쉬운 ‘자투리 시간’을 유의미한 활동으로 전환할 수 있도록** 돕습니다. 사용자의 시간 활용을 지원하는 일정 관리/루틴 관리 서비스와 같이 단순 생산성 향상과 시간 관리 가이드라인 제공을 목표로 하지 않는다는 점에서 차별화됩니다. + +특히 추천 카테고리의 다양화와 사용자 아카이빙 기록 시각화를 통해 재미 요소를 추가했으며, AI 기반의 추천 엔진(OpenAI, Clova AI)을 활용해 온/오프라인별 확장된 사용자 맞춤형 콘텐츠를 제공할 수 있습니다. + + +> **목표 사용자** +> + +‘조각조각’의 주요 목표 사용자는 **자투리 시간이 자주 발생하고, 시간을 의미있게 또는 다양한 방식으로 활용하고자 하는 니즈**가 있는 사람입니다. + +**`주요 타겟`** + +- 출퇴근 시간이나 학업 중 자투리 시간이 빈번히 발생하는 사람 +- 시간의 효율적인 활용 및 성취감을 중시하는 자기개발 니즈가 강한 사람 +- 반복적인 일상에서 벗어나 색다른/즐거운 활동을 경험하고 싶은 사람 + +# 🤔 문제 인식(Problem) + +## 서비스 개발 동기 + +‘하루 중에 버려지는 시간이 너무 많다 …’ 이런 생각 드신 적 있으신가요? + +배차 간격이 긴 버스를 기다릴 때, 갑자기 수업이 휴강 되었을 때, 약속 시간에 일찍 도착했을 때 … 무언가 새롭게 하기엔 애매한 것 같고, 멍하니 보내기엔 왠지 아까운 그 시간! + +>💡 팀 시너지는 우리의 일상에서 **누구에게나 발생할 수 있는 자투리 시간의 활용 한계와 아쉬움에 주목해**, +> **모든 사람들이 자신만의 의미있는 자투리 시간을 만들 수 있는 방법**을 찾아 ‘조각조각’ 서비스를 고안하게 되었습니다. + + +## 서비스 목적 및 필요성 + +‘조각조각’은 자투리 시간을 가볍지만 의미 있게 활용할 수 있는 방법을 제안해, **자투리 시간 활용에 대한 니즈와 페인포인트를 충족**합니다. +- 사용자의 시간과 상황, 취향을 반영한 개인화된 추천으로 `자투리 시간`을 `온전한 ‘내 시간'`으로 만들 수 있도록 돕습니다. +- 사용자가 자투리 시간에 대한 인식을 `버려지는 시간`, `애매한 시간`이 아닌 `’무엇이든 가볍게 시도해볼 수 있는 시간’`으로 전환할 수 있도록 만듭니다. + +> **1️⃣ 시간의 효율적 활용에 대한 니즈 확산** + +💡 **시간을 효율적으로, 잘 활용할 수 있는 방법을 제안하는 시의성 있는 서비스** + +시간의 가치를 중요하게 생각하는 사회적 동향과 함께, 24시간을 효율적으로 쓰고 싶어하는 니즈가 사회적으로 확산 되고 있습니다. + +- 2024 트렌드 코리아 키워드로도 선정된 ‘분초사회’는 시간이 중요성이 매우 강조되는 사회로, 시간의 효율성을 극대화하기 위해 분초(分秒)를 다투며 산다는 의미입니다. 이처럼 현대사회에서 시간의 중요성이 높아지면서 ‘시성비’라는 용어도 등장할 만큼 시간의 ‘가성비’를 중시하는 현상이 일어나고 있습니다. +- CJ ENM의 디지털마케팅 기업 메조미디어의 조사에 따르면 현대사회 사람들은 ‘현대사회에서 시간은 가장 큰 자원(82%)’이라고 생각하며 ‘남들보다 24시간을 효율적으로 써야 나의 가치를 더 높일 수 있다’고 답했습니다. **이는 시간의 가치를 무엇보다 중요하게 생각한다는 결과로, 시간을 효율적으로 사용하고자 하는 니즈가 사회적으로 퍼져있음을 확인할 수 있습니다.** + +출처: [분초사회](https://terms.naver.com/entry.naver?docId=6712649&cid=43667&categoryId=43667), [이명진 가자 내 시간은 '가장 큰 자원'... 시간 아끼는 '초단축 소비' 트렌드 대세](https://www.banronbodo.com/news/articleView.html?idxno=23135) + +> **2️⃣ 일상에서 빈번히 발생하는 자투리 시간에 대한 아쉬움** + +💡**매일 의미없이 버려지는 자투리 시간에 대한 아쉬움과 피로감을 해결할 수 있는 서비스** + + +우리의 24시간 중 자투리 시간은 얼마나 발생하고 있을까요? + +2019년 취업사이트 게임잡 설문 결과, 성인남녀의 자투리 시간은 **하루 평균 2시간 반(147분)** 에 달한 것으로 나타났습니다. 직장인은 하루 평균 127분, 대학생은 160분의 자투리 시간이 발생하며, **2명 중 1명(44.9%)은 '매일 발생하는 자투리 시간이 아깝다'** 고 답변했습니다. + +자체적으로 진행한 유저리서치에서도 20대 성인남녀가 **자투리 시간이 ‘버려지는 시간’ 또는 ‘무언가를 하기엔 짧고 애매한 시간’으로 여겨지며, 그 시간이 아깝다고 느낀다**고 답변했습니다. 특히 직장인의 경우, 출퇴근 과정에서 발생하는 **자투리 시간의 반복성과 긴 시간에 불편함**을 표하고 있었습니다. + +- '자투리시간 사용 행태 및 불편 사항'에 관한 설문조사 결과, 20대 설문 응답자가 자투리 시간 발생에 대해 약 80%가 불편함을 느끼고 있다고 답변했으며 그에 대한 이유는 버려지는 시간의 아까움(71.6%), 짧고 애매한 시간으로 무엇을 할지 고민 됨(58.1%)이 상위 응답으로 나타났습니다. +- 경기·인천 통학·통근 인구 153만 명 중 90% 이상(141만 명)이 서울로 이동하고 있으며(2020년 인구주택총조사), 직장인들이 출퇴근을 위해 하루에 소요하는 시간은 평균 1시간 24분으로 밝혀졌습니다(2022년 잡코리아 조사). 이러한 직장인들에게 출퇴근 길에 느끼는 피로도를 점수로 환산(*100점 만점 기준)하게 한 결과, 경기권은 74점, 서울과 지방은 71점을 보이며 직장인의 출퇴근 피로도의 심각성을 확인할 수 있었습니다. 이들에게 피로감을 느끼는 이유에 대해 복수응답으로 꼽아보게 한 결과로는 “오늘도 어김없이 출근이라는 현실 때문에 스트레스를 받는다”는 의견이 63%로 가장 높은 비율을 차지함이 드러났습니다. + +출처: [메트로신문 성인남녀 '하루 2시간여 자투리 시간'에 하는 것 톱5… 10명 중 8명은 '온라인 활동'](https://www.metroseoul.co.kr/article/2019102900083), [윤화정 기자 직장인 출퇴근 소요시간 평균 '1시간 24분'](http://www.worktoday.co.kr/news/articleView.html?idxno=26284) + +> **3️⃣ 반복되는 일상 속 새로운 활동에 대한 니즈** + +💡**남는 시간을 성취감 있게, 재미있게! 사용자에게 가치 있는 시간을 만들어 주는 서비스** + +그렇다면, 사용자는 자투리 시간에 **어떤 활동**을 하고 싶을까요? + +유저리서치 결과, 20대 설문 응답자들은 자투리 시간을 의미있게 만들기 위해 중요하게 생각하는 가치로 **습관 형성, 성취감, 재미있는 경험, 새로운 학습/지식 습득**을 꼽았습니다. 시간을 가치 있게 사용하고 싶어하는 성향이 반영되어 성취감을 느낄 수 있는 활동을 선호하는 것으로 판단됩니다. 실제로 통학 및 출퇴근 시간을 활용해 학습 앱으로 공부하며 체계적으로 시간을 관리하고, 자신만의 학업 경쟁력을 높이는 ‘틈새 학습 앱’이 인기를 끈다는 점에서도 그 니즈를 확인할 수 있었습니다. + +한편, Z세대를 중심으로 **일상적 상황에서 벗어난 재미있는 경험을 추구**하는 경향도 나타나고 있습니다. 낭만을 쫓는 ‘굳이 데이’, ‘도파민’ 등의 용어가 활발하게 쓰이며 색다르고 다양한 삶의 경험을 영위하고자 하는 니즈가 확산되고 있습니다. + +- '자투리시간 사용 행태 및 불편 사항'에 관한 설문조사 결과, 20대 설문 응답자의 92% 이상이 ‘자투리 시간을 의미있게 보내고 싶다’고 답했으며, ‘자투리 시간을 의미있게 만들기 위해 중요하다고 생각하는 가치’에 대해 습관 형성(57.3%), 성취감(50.6%), 재미있는 경험(46.1%), 새로운 학습/지식 습득(37.1%) 순으로 응답했습니다. +- 프로통학러들 사이에서 통학시간에 틈새 학습 앱으로 공부하며 체계적으로 시간을 관리하고, 자신만의 학업 경쟁력을 높이는 학습법이 각광받고 있습니다. 이러한 흐름에 발맞춰 YBM인강, 똑똑보카, 오르조 등 교육업계 서비스에서도 하루 30분 이상 도로에서 시간을 소모하는 통학러(통학생)들을 위해 언제, 어디서나 편하게 자투리 시간을 활용해 공부할 수 있는 학습 앱을 속속 선보이고 있습니다. +- Z세대 사이에서 ‘도파민’이 큰 화두입니다. 도파민은 스트레스를 완화해주고 기쁨을 느끼게 하는 우리 몸에 필수적인 호르몬입니다. 이에 따라 몰입이나 성취를 통해 느리지만 자연스럽게 도파민을 얻는 방식이 최근 떠오르고 있습니다. 더불어 최근 Z세대 사이 ‘낭만’이라는 단어가 자주 사용됩니다. 도파민을 추구하며 ‘낭만’이라는 이름으로 긍정적으로 색다른 경험들을 쫓고 다양한 삶의 경험을 영위하고 있습니다. + +출처: [이준문 기자 ‘프로통학러’ 학습 경쟁력 높이는 ‘틈새 학습 앱’ 각광](http://m.newstap.co.kr/news/articleView.html?idxno=204208), [캐릿이 4년 간 분석한 Z세대의 새로운 특징 ‘MZBTI 3.0’](https://www.careet.net/1293) + +# 👥 사용자 **(User)** + +## 유저 리서치 + +### Survey + +> 데스크 리서치를 통한 문제 인식과 배경 조사를 기반으로 96명에게 ‘자투리 시간 활용 행태 및 불편함’에 대한 설문 조사를 진행하였습니다. + + +**1️⃣ 자투리 시간 발생 현황 및 사용 행태** + +사람들이 자투리 시간을 어떻게 활용하고 있는 지 알아보았습니다. + +![survey](https://github.com/user-attachments/assets/b3009b4e-74a1-41e8-b419-a557cb04dcec) +![survey-1](https://github.com/user-attachments/assets/dcc05c0f-0cfe-4acc-b77d-37ab047127f2) + +- 응답자의 85% 이상이 자투리 시간이 자주 발생한다고 답변했으며, 주로 대중교통을 이용하거나 주요 일정의 휴식 시간에서 발생. +- 압도적인 수치로 소셜 미디어 및 음악 감상/영상 시청이 높은 편이었으며 이 외에도 책, 뉴스, 게임 등을 통해 자투리 시간을 활용하고 있음. + - 해당 활동으로 자투리 시간을 활용하는 이유로는 간편함, 높은 접근성, 생산성, 습관적, 휴식 등을 꼽았으며 어떤 일을 하기 애매한 시간이라 흘려보낸다는 답변도 많았음. + +⇒ 자투리 시간이 **애매한 시간이라는 인식**으로, 대부분 **간편하고 접근성이 높은** 휴대폰을 통한 활동을 한다는 것을 확인할 수 있었음. + +**2️⃣ 자투리 시간에서 느끼는 불편함** + +자투리 시간 발생 시 어떤 불편함을 느끼는 지 알아보았습니다. + +![survey-2](https://github.com/user-attachments/assets/3e535aad-4c7e-4605-a1b2-6568b0aa2de8) + +- 자투리 시간 발생에 대해 약 80%가 불편함을 느끼고 있다고 응답 + - 이에 대한 이유로는 버려지는 시간의 아까움, 짧고 애매한 시간으로 무엇을 할 지 고민 됨, 과도한 핸드폰 사용량, 지루함 등을 이유로 꼽음 + +⇒ 대부분의 사람들은 버려지는 시간을 아까워하지만 ‘자투리 시간’이 애매하다는 인식으로 어떤 일을 해야 할 지 모르고 있음. + +**3️⃣ 자투리 시간 활용 시 주요 가치** + +자투리 시간 활용에서 가장 중요하게 느끼는 것이 무엇인지 알아보았습니다. + +![survey-3](https://github.com/user-attachments/assets/67f217ca-6d5b-465d-8c2b-73d1a82d57a8) + +- 응답자의 92% 이상이 자투리 시간을 의미있게 보내고 싶다는 니즈가 있음 +- 자투리 시간 활용에서 중요하게 생각하는 것은 습관 형성, 성취감, 재미있는 경험 등 + +### In-Depth Interview + +> 시행 기간: 2024.10.10 ~ 10.13 총 4일간 +> +> 대상자: 자투리 시간을 의미있게 보내고 싶은 응답자&자투리 시간 활용에 불편함을 느끼는 응답자 중 5명 + +In-Depth Interview를 진행하기에 앞서, 인터뷰이들의 자투리 행태를 보다 면밀히 파악하기 위해 +자투리 시간 활용에 대한 자가 기록 연구를 부탁했습니다. + +> 🖊️ **[자가 기록 연구 내용]** +> +> 자투리 시간이 발생할 때마다 해당 폼을 통해 활용 행태를 적도록 함. +> +> 유의미한 정보 수집을 위해 최소 하루 이상의 자가 기록을 진행할 수 있도록 하였으며, 구글폼의 주어진 양식을 통해 제출할 수 있도록 하여 사용자의 기록 편의성을 높일 수 있게 함. +> - **자투리 시간 사용 전 :** 자투리 시간이 발생한 상황 (발생 이유/현재 시각/장소/발생한 자투리 시간) +> - **자투리 시간 사용 후 :** 자투리 시간 사용 행태 (사용 방법/이유/감정) + +자가 기록 연구와 서베이 응답을 기반으로 각 인터뷰이 별로 30분 내외의 심층 인터뷰를 진행하였고, +유사한 응답을 묶어 Affinity Diagram을 진행하였습니다. + +Affinity Diagram + +1. 자투리 시간에 대한 인식 + ⇒ 대부분의 인터뷰이들은 자투리 시간을 ‘애매하게 남는 시간’, ‘아까운 시간’, ‘예상치 못하게 발생하는 시간’이라고 인식하고 있었음. 이로 인해 해당 시간에 무엇을 하면 좋을 지 모르겠다고 응답함. + +2. 자투리 시간을 보내는 행태 + ⇒ 유의미한 일을 하고 싶다고 생각하지만 막상 자투리 시간이 다가오면 습관적으로 핸드폰을 키거나 무의미하게 흘려보내게 된다고 답해주었음. + +3. 자투리 시간을 보내는 것에 대한 아쉬움 + ⇒ 모든 응답자가 하고자 하는 일을 못하고 의미없이 릴스, SNS 등 내가 ‘목적’으로 하지 않은 일을 하게 될 때 아쉬움, 나에 대한 부정적인 감정, 회의감 등을 느끼게 됨. + +4. 자투리 시간에 대한 니즈 + ⇒ 사용자마다 어떤 방식으로 자투리 시간을 보내고 싶은 지는 상이했음. 자기개발, 독서, 스트레칭, 일정 관리 등. 하고 싶은 행위는 모두 달랐지만 근본적으로 자신이 유의미하다고 여기는 가치 및 목적에 대한 행위를 통해 시간을 낭비하지 않고 알차게 보내기를 바람. + + +) 유튜브/릴스가 이미 재미와 흥미를 제공하고 있지만 이에 대한 행위에 부정적 감정을 느끼는 이유는 ‘목적'이 없는, 나의 선택이 들어가지 않은 알고리즘에 의한 콘텐츠 소비이기에 의미가 없음. + +5. 서비스 기능 관련 + ⇒ 자투리 시간을 활용할 수 있는 활동을 추천 받는다면 본인에게 그 행위가 얼마나 유의미하고 매력적으로 다가올 지 중요할 것 같다고 응답. 또한 ‘자투리 시간’인만큼 부담스럽고 제약이 있는 활동 보다는 가볍고 편안한 활동을 원한다고 응답함. + +## 서비스 목표 타겟 정의 + +> 앞선 유저 리서치를 바탕으로, 자투리 시간에 대한 인식과 사용 니즈에 기반하여 서비스 목표 타겟을 정의하였습니다. + +1) 자투리 시간이 ’무엇을 하기에는 애매하게 남는 시간‘이라는 인식 + + → 무엇을 할 지 모르고 그냥 습관적으로 흘려보내게 되는 사람들이 대부분. + + → 결국 나의 의지가 담기지 못한 채 ‘목적성’을 잃고 흘려보내기 때문에 유의미한 활용이 어려움. +2) ‘자투리 시간’인 만큼 부담스럽고 거창한 활동보다 가볍고 접근성이 높은 활동에 대한 니즈 존재 + + +> ❗**예상치 못하게 발생하는 자투리 시간을 무의미하게 흘려보내지 않고** +> **가볍고 다양한 활동을 통해 ‘나만의 시간’으로 만들어가고 싶은 사람들** + + + +## Persona & Journey Map + +> 유저리서치 내용 및 인사이트를 바탕으로 서비스를 사용하는 메인 페르소나를 도출하였습니다. +> + +![페르소나](https://github.com/user-attachments/assets/80358cd9-76a9-4a58-b7d0-26baef8925ee) + +![저니맵](https://github.com/user-attachments/assets/0447c2db-da73-4755-9c35-a16e189e5d58) + +## 👩‍💻 서비스(Service) + +### 서비스 카테고리 + +> AI 기반 자투리 시간 활용 방향 추천 플랫폼 + +### 타겟특화 포인트 + +> 👥 자투리 시간을 유의미하게 보내고 싶은 사람을 대상으로, 자투리 시간에 적합한 온라인/오프라인 활동을 `개인 맞춤형으로 추천`함으로써 자투리 시간을 `온전한 ‘내 시간’`으로 만들어갈 수 있도록 함 + +### 사용자에게 제공하는 혜택 + +- **`인식의 전환`** : 자투리 시간을 ‘무엇인가를 제대로 하기에 애매한 시간’이 아닌 ‘무엇이든 가볍게 시도해볼 수 있는 시간’으로 인식을 전환시킨다. +- **`‘나의’`** : **추천 온보딩**을 통해 오로지 ‘나의’ 자투리 시간을 만들어 갈 수 있도록 한다. +- **`부담없는`** : 부담 없이 일상 속에서 자투리 시간을 유의미하게 보낼 수 있는 **활동을 추천**해준다. +- **`쌓아가는`** : 활용한 자투리 시간을 **아카이빙**을 통해 쌓아가며 효능감/성취감을 느낄 수 있도록 한다. + +### 서비스 플로우 + +> **IA** + +![IA](https://github.com/user-attachments/assets/d804d0ea-1416-46a2-adc5-f7b2a3931d52) +> **전체 서비스 플로우** +> + +![전체 서비스 플로우](https://github.com/user-attachments/assets/36424b42-6082-4368-ba1a-6023a1bc51b4) + +> **기능별 세부 플로우** +> + +**`로그인 플로우`** + +![로그인 플로우](https://github.com/user-attachments/assets/f0b64c43-278e-4b3a-aef3-19e8fc91ea46) + +**`메인홈 - 추천 플로우`** + +![메인홈 플로우](https://github.com/user-attachments/assets/261afe83-0b6e-4840-9c99-b1948f3cf8f3) + +**`아카이빙 플로우`** + +![아카이빙 플로우](https://github.com/user-attachments/assets/c8ef4ad2-17d6-4b69-b3e8-37cc2a4917ee) + +**`마이페이지 플로우`** + +![마이페이지 플로우](https://github.com/user-attachments/assets/02684727-2900-4d1d-99d5-5c59f7f49a85) + +### **서비스 포인트 (참신성, 차별성 등)** + +> **유사 서비스 분석** + +| 서비스명 | 서비스 유형 | 메인 기능 및 특성 | 사용 목적 | +|-------------------|-------------------------|-------------------------------------------------------------------------------------------------------|---------------------------------------------------| +| **오늘 뭐할지 GetGPT** | AI 추천 | ChatGPT형 AI 추천 서비스
- 하루 할 일 추천 GPT
- 프롬프트 작성 필요
- 기본 질문 존재
- 아카이빙 불가능 | GPT와의 대화를 통해 하루 할 일 추천 가능 | +| **투두메이트** | 시간 활용 지원 (일정 관리) | 할 일 및 목표 리스트 생성
- 우선순위 설정
- 일정 알림 기능
- 완료 체크를 통한 성취감 제공 | 일정 관리 및 효율적 시간 분배를 통한 생산성 향상 | +| **마이루틴** | 시간 활용 지원 (루틴 관리) | 개인 맞춤형 루틴 설정 및 계획
- 알림을 통한 지속적 습관 형성
- 활동 기록 및 성과 시각화 | 습관 형성 및 시간 관리 개선을 통해 생산성 향상, 목표 달성 도움 | +| **조각조각** | 시간 활용 지원 및 AI 활용 추천 | 자투리 시간 활용 방법 추천
- 추천 활동 유형/카테고리의 다양성
- 아카이빙을 통한 시간 사용 분석 및 효용성 향상 | 버려지는 자투리 시간을 유의미한 활동으로 전환 | + + +> 💫 **참신성 & 차별성** +> +>- 자투리 시간에 대한 인식 변화를 통한 사회적 임팩트 부여 + > - 무엇인가를 제대로 하기에 애매한 시간 → 무엇이든 가볍게 시도해볼 수 있는 시간 +>- 온라인 활동 추천 + > - 사용자가 입력한 추천 온보딩 데이터를 기반으로 다양한 활동 추천 +> - 모든 사용자가 쉽게 접근할 수 있는 온라인 활동을 추천함으로써 시공간적 제약을 최소화하여 자투리 시간을 활용할 수 있도록 함 +>- 오프라인 추천 + > - 사용자 위치를 기반으로 방문 장소까지 구체적인 추천 +> - 사용자가 일상 속에서 쉽게 지나쳤던 장소 혹은 숨겨진 장소들을 재발견할 수 있도록 함으로써 지역 사회를 활성화 + +### 핵심 기능 + +| **기능** | **설명** | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **추천 온보딩** | 활동 추천을 위해 진행하는 온보딩으로, 사용자가 현재 상황에 따라 올바른 추천을 받을 수 있도록 합니다.
- **시간 선택**: 현재 사용할 수 있는 자투리 시간을 선택합니다. (10분 이상)
- **온라인/오프라인 선택**: 현재 사용자가 위치하고 있는 공간이 실내인지 실외인지 선택합니다.
- **활동 키워드 선택**: 사용자가 선호하는 활동의 형태를 선택합니다. | +| **활동 추천** | 앞선 온보딩을 기반으로 사용자에게 맞는 활동을 추천합니다.
- **온라인 활동**: 사용자의 휴대폰을 통해 할 수 있는 온라인 활동 추천
- **오프라인 활동**: 사용자의 현재 위치를 기반으로 할 수 있는 오프라인 활동 추천

사용자는 각 주제 별로 추천되는 5개의 활동 중 원하는 활동을 선택합니다.
만약 오프라인 활동에서 특정 장소 선택 시, 해당 장소의 위치 링크를 연결합니다. | +| **아카이빙** | 사용자가 해당 활동을 끝내고 나면, 해당 기록이 저장됩니다.
저장된 기록은 트리맵 형태의 ‘활동 키워드’와 캘린더 형태의 ‘활동 캘린더’로 확인할 수 있습니다. | + + + +### 서비스 비즈니스 모델 +오프라인 추천 시 매장과 제휴 + +제휴 매장을 오프라인 추천해줌으로써 비즈니스 모델 구현한다. + +1. 오프라인 매장과의 제휴 체결한다. +2. 해당 매장이 사용자의 온보딩 정보와 부합하는 경우, 오프라인 활동에서 추천한다. + +> 💵 **수익구조** +> 매장 → 조각조각 : 제휴 광고 수수료 제공 +> 조각조각 → 사용자 : 제휴 매장의 광고를 맞춤형으로 제공 + +> 📌 **기대효과** +> 매장 : 사용자 데이터를 바탕으로 한 개인화 된 추천을 통해 맞춤형 광고 가능 +> 사용자 : 현재 접근 가능한 맞춤형 매장을 추천 받을 수 있음 + +린캔버스 + +![도식화](https://github.com/user-attachments/assets/9c3337ba-d18a-4209-9194-6e0ce6e4eca9) + + +## 🎨 디자인 (Design) + +### 로고 및 디자인 시스템 + +> **디자인 컨셉** + +> ⏰ +> **조각조각의 디자인 컨셉** +> 조각조각은 우리가 일상 속에서 모을 수 있는 자투리 시간을 **‘시간 조각’** 이라고 정의합니다. +> 조각조각은 우리가 평소에 흘려보내던 일상 속의 자투리 시간을 색다르게 활용하게 함으로써 +> ‘시간 조각’을 모으는 경험을 제공합니다. 조각조각 내에서의 시간 조각은 엔터테인먼트, 소셜, 건강, +> 자기개발, 문화 / 예술, 휴식 크게 6개 종류로 나뉩니다. 각각의 조각들은 분야에 맞게 형상화 되어있습니다. (ex: 휴식은 머그컵이라는 오브제로 형상화) +> +> **형상화된 시간 조각을 찾고, 모으고, 쌓고, 보면서 조금 더 색다르고 의미있는 시간을 찾길 도와줍니다.** + +![design](https://github.com/user-attachments/assets/b3de7f66-42b8-48a3-b009-d9d31c077f32) + +![design 1](https://github.com/user-attachments/assets/b600155d-c451-4238-8955-e18971039d4c) + +![design 2](https://github.com/user-attachments/assets/3fb72ba5-4263-4b2f-9bf1-adb6dfb613ae) + +> ❔**왜 도트에서 clay 질감의 3d 그래픽으로 전환하게 되었나요?** +> +> 기존 도트 그래픽이 가진 문제점을 해결하고자 했습니다. +> 1. 사각형, 각진 그래픽으로 인해 동적인 느낌이 부족해 보인다는 점 +> 2. 단순히 도트 그래픽으로는 서비스 GUI를 완성도 있게 보여줄 수 없다는 한계점 +> 3. 도트 그래픽과 UI 컴포넌트의 조합 및 비중 설정을 잘못하게 될 시 서비스가 어수선하게 보일 수 있다는 리스크 +> 4. 초기 기획했던 가볍고 마치 간식을 꺼내 먹는 듯한 느낌을 추구하는 서비스 무드보다는 게임/게이미피케이션 서비스 무드에 가깝게 느껴지는 문제점 +> +> → **서비스 무드와 콘텐츠 성격, 높은 디자인적 완성도 측면을 고려하여** 그래픽 디자인을 변경하였습니다. + +> ❔**그렇다면 왜 clay 질감의 3d 그래픽인가요?** +> +> UI 요소나 컨텐츠가 많이 들어가지 않는 조각조각의 서비스 특성상 그래픽을 좀 더 다이나믹하게 보여줄 수 있는 그래픽이 필요하다고 판단했습니다. +> 단순 도트 그래픽으로는 화면 요소를 입체감 있게, 그리고 동적으로 보이게 구성하기가 어렵기 때문에 대안으로 다양한 3d 그래픽을 시도하게 되었습니다. +> 여러 대안 중 질감이 덜 들어가는 clay 질감의 그래픽이 가장 가벼운 느낌을 주었고, 서비스 무드와 매치하는 요소로 판단하여 clay 질감을 활용하게 되었습니다. + + +### 화면 디자인 + +![design 3](https://github.com/user-attachments/assets/cb701119-3e5d-4355-b36d-68126782ec9a) + +![design 4](https://github.com/user-attachments/assets/0ecc4b38-df03-43a7-9737-ea86a022b42b) + +![design 5](https://github.com/user-attachments/assets/f83bd29e-be7f-45f8-810c-764cf0982594) + +![design 6](https://github.com/user-attachments/assets/61e2e55b-6ca7-406a-92d7-f742d07661c3) + +![design 7](https://github.com/user-attachments/assets/7d4eb594-0c84-4953-a7c3-5186f0ba7f93) + +![design 8](https://github.com/user-attachments/assets/e25a099b-197a-4f7e-addd-4eb932609456) + +![design 9](https://github.com/user-attachments/assets/f85a7b0f-1bf0-4209-8861-a99d7fcae7b7) + +![design 10](https://github.com/user-attachments/assets/ec805e77-35c1-4f6a-80f9-0669c92e7faf) + +# 💻 개발(Development) + +> **Github URL:** [https://github.com/KUSITMS-30th-TEAM-C](https://github.com/KUSITMS-30th-TEAM-C) +> +> **배포 URL:** [https://cnergy.kro.kr/](https://cnergy.kro.kr/) + +## 개발 환경과 사용 기술 스택 + +### ❇️ 프론트엔드 기술 스택 및 선정 이유 + +- **Next.js 14**: + - **서버 사이드 렌더링(SSR)** 및 **정적 사이트 생성(SSG)** 지원으로 SEO 향상과 빠른 페이지 로딩 제공. + - **Full-stack 기능** 제공으로 API 라우팅과 프론트엔드를 통합할 수 있어 효율적인 개발 가능. + - **Edge Functions**와 같은 최신 성능 최적화 기능을 활용할 수 있음. +- **React 18**: + - **Concurrent Mode**를 통해 성능을 향상하고, 대규모 애플리케이션에서 더욱 부드러운 UI 렌더링. + - 최신 **Hook 기반 API**로 코드 간결성 및 상태 관리 향상. + - 커뮤니티와 생태계가 매우 크고, 다양한 서드파티 라이브러리와의 호환성이 좋음. +- **Tailwind CSS**: + - **유틸리티 우선 CSS 프레임워크**로, 빠르게 일관된 스타일링 가능. + - 클래스 단위로 CSS를 관리하기 때문에 유지보수성이 높고, 불필요한 CSS 코드 생성을 최소화. + - 커스텀 디자인을 쉽게 적용하면서도 **반응형 디자인**을 유연하게 구현할 수 있음. +- **eslint - airbnb**: + - **코드 스타일 일관성**을 유지하고, **버그를 예방**하기 위해 AirBnB 스타일 가이드를 활용한 Linting 도구. + - 코드 품질 향상과 팀 간 협업 시 일관된 코딩 스타일을 유지. +- **Prettier**: + - **자동 코드 포매팅**을 통해 코드 스타일을 일관되게 유지. + - 개발자의 생산성 향상과 코드 리뷰 시 시각적 노이즈를 줄일 수 있음. +- **Storybook**: + - **UI 컴포넌트 개발 및 테스트 도구**로, 컴포넌트 단위 개발을 효율적으로 진행. + - **디자인 시스템**을 관리하고, 컴포넌트 간의 재사용성을 높임. +- **TanStack Query & ContextAPI**: + - 효율적인 비동기 처리를 위해 사용 +- **CI/CD (GitHub Actions, NCP, Docker)**: + - **GitHub Actions**: 코드 변경 시 자동화된 빌드, 테스트, 배포 파이프라인을 설정하여 개발 프로세스의 효율성 증대. + - **NCP (Naver Cloud Platform)**: 안정적인 인프라 제공 및 한국 지역 기반 서비스 운영 시 유리. + - **Docker**: 개발 환경을 컨테이너화하여 일관성 있는 환경에서 애플리케이션 배포 가능. +- **Zustand**: + - **가벼운 상태 관리 라이브러리**로, Redux보다 간단하고 코드가 간결해지며, 리액티브한 글로벌 상태 관리에 적합. + +### ❇️ 백엔드 기술 스택 및 선정 이유 + +- **Java 17** + - LTS(Long-Term Support) 버전으로 2029년 9월까지 지원합니다. + - 최신 LTS 버전인 JDK21을 바로 사용하는 것보다 그 전 LTS 버전인 JDK 17을 사용하여 추후 JDK 21로 마이그레이션시 영향을 줄일 수 있기 때문입니다. + - Spring Boot 3.0부터는 JDK 17 이상부터 지원하므로 JDK17을 사용하였습니다. +- **Spring Boot 3.3.4** + - 이번 프로젝트에서는 WebSocket을 이용한 채팅 기능을 서비스에 도입하지는 않았지만, Spring Boot 3.3 버전에서 제공하는 WebSocket의 가상 스레드 지원을 활용하여 동시성을 효율적으로 처리할 수 있는 점을 고려해 사용했습니다. +- **Spring Data JPA** + - SQL을 직접 작성하지 않고 객체지향적인 코드로 데이터베이스를 다루기 위해서 JPA를 사용하고, Spring 프레임워크에서 JPA를 쉽게 사용할 수 있는 모듈인 Spring Data JPA를 지원하기 때문에 사용하였습니다. +- **MySQL 8.x** + - 5 버전에 비해 향상된 성능, 강화된 보안 기능, 공간 데이터 처리 등을 사용하기 위해 사용했습니다. +- **Docker** + - 개발 및 배포 환경을 쉽게 컨테이너화하기 위해 사용했습니다. +- **Github Actions** + - Github와 하나로 통일된 환경에서 CI를 수행하기 위해 사용했습니다. +- **Naver Cloud Platform** + - 한국에서 만든 클라우드 서비스로 서버, AI 등 프로젝트에 필요한 서비스를 사용했습니다. +- **Sentry** + - 에러 로그 모니터링과 중앙집중적 에러 로그 관리를 위해 사용했습니다. +- **Swagger** + - 클라이언트, 서버 간 API 명세서 용도로 사용했습니다. +- **OpenAI** + - OpenAI는 다양한 온라인 활동 추천을 제공하는 데 강점이 있습니다. 이를 통해 사용자의 관심사에 맞는 폭넓고 창의적인 추천을 만들어 더 다양한 활동 선택지를 제공할 수 있어 사용하게 되었습니다. +- **RabbitMQ** + - RabbitMQ의 “Delayed Message Exchange Plugin”을 활용하여, 종료 로직을 특정 시간만큼 지연된 메시지로 전달하는 방식으로 구현했습니다. + - 비동기적으로 종료 작업을 처리하여 주요 트랜잭션과 분리할 수 있는 장점이 있어 사용했습니다. + +## 🟩 NCP 사용 스택 + +- **Server** + - 백엔드 배포 서버로 사용했습니다. + - 프론트엔드 배포 서버로 사용했습니다. +- **VPC** + - 클라우드 내 전용 네트워크를 확보하기 위해 VPC를 사용했습니다. + - 현재 프론트엔드와 백엔드가 하나의 VPC내 다른 Subnet으로 구성되어있습니다. + - 추후 백엔드 Subnet을 Private으로 설정해 외부접근을 제한하고, 프론트엔드 서버를 통해서만 접근할 수 있도록해 보안을 강화할 예정입니다. +- **Container Registry** + - 백엔드 및 프론트엔드 모두 Docker를 사용해 배포를 진행합니다. Public Registry에 이미지를 저장할 경우, 민감한 정보들을 관리하기 어렵기에 사설 Registry인 NCP Container Registry를 사용했습니다. + - Container Registry 의 경우 레지스트리에 등록된 이미지의 취약점을 분석해 정보를 제공해줍니다. 이를 통해 컨테이너의 취약점을 제거해 컨테이너를 더욱 안전하게 사용할 수 있습니다. +- **Naver Clova Studio** + - 한국 사람들이 가장 많이 사용하는 검색엔진의 데이터를 활용하여 개발되었기 때문에, 한국 사용자에게 활동을 추천하는 프로젝트에 적합하다 판단해 사용합니다. + - 사용자에게 현재 상황과 취향을 입력받아 활동 추천 시 Clova AI를 사용합니다. + - 모델을 튜닝하여 사용자에게 다양한 활동을 추천합니다. +- **NAVER Object Storage** + - 오브젝트 스토리지 버킷을 NCP CDN과 연결하여 CDN 서버에서 빠르고 효율적으로 이미지 제공 + + +## 🏛️ 시스템 아키텍처 + +![시스템 아키텍처](https://github.com/user-attachments/assets/566c2abc-8974-4135-a78b-ccf60c8ed6d8) + +## 🧱 ERD + +![ERD](https://github.com/user-attachments/assets/487b3527-c172-4e6c-84b9-a84dd45e8c48) + +## 🌊 개발부터 배포까지의 워크플로우 + +> **1. 개발 단계** +> +- Git을 사용한 버전 관리 +- 브랜치 전략 적용 (Git-flow) +- Pull Request를 통한 코드 리뷰 + - reviewer의 approve 없이 dev 브랜치에 push 불가 + +> **2. CI (지속적 통합) 구축** +> +- GitHub Actions 사용 +- 자동 빌드 및 테스트 실행 +- Docker 이미지 빌드 및 Registry 푸시 + +> **3. 배포 준비** +> +- Docker Compose 를 통해 배포(SpringBoot, Redis, Rabbit MQ) + +> **4. 배포 프로세스** +> +1. CI/CD 파이프라인에서 새 Docker 이미지 빌드 및 푸시 +2. 운영 서버에서 최신 이미지 pull +3. Docker Compose로 서비스 업데이트 + +> **5. 모니터링 및 로깅 (Sentry 중심)** +> +- Sentry 대시보드 모니터링 +- 실시간 에러 추적 +- Grafana와 Prometheus를 활용한 서버 모니터링 + +## 💻 프론트 - 비동기 데이터 페칭 최적화 및 이미지 로딩 성능 향상 + +### **AsyncBoundary+tanstack-query를 이용한 데이터 페칭** + +- tanstack-query를 이용한 데이터 캐싱 +- SSR환경에서 사용 가능한 Suspense 구현 +- AsyncBoundary를 통한 비동기 데이터페칭 에러/로딩 상태 선언적 관리 +- AsyncBoundaryWithQuery 구현 : tanstack-query의 useQueryErrorResetBoundary를 활용하여 데이터 페칭 중 오류 발생 시 데이터 리페치 +- querykey가 바뀔때마다 컴포넌트 깜빡임(fallback 노출) 이슈 + - useTransition 훅을 활용하여 상태 업데이트를 UI 렌더링보다 지연시킴으로써 데이터 페칭 중 발생하는 깜빡임을 방지 + +### **NCP CDN과 Next.js Image태그를 이용한 빠른 이미지 로딩** + +- NCP 오브젝트 스토리지와 연동하여 정적 리소스의 캐싱 및 전달을 효율적으로 수행 +- **자동 최적화**: 브라우저와 디바이스에 맞는 해상도와 크기로 이미지를 자동 변환 +- **레이아웃 시프트 방지**: 이미지의 고정된 크기를 제공하여 CLS(Cumulative Layout Shift) 이슈 방지 + +## 💻 백엔드 - 아키텍처 설계: 도메인 주도 설계(DDD) 적용 + +- 도메인을 중심으로 아키텍처를 설계하여 비즈니스 로직을 명확하게 분리하였습니다. +- 표현, 응용, 도메인, 인프라스트럭처 계층으로 분리하여 각 계층의 책임을 명확히 정의하고, 역할에 맞는 책임 분담을 실현하였습니다. +- 의존 역전 원칙(DIP)을 적용하여 상위 계층이 하위 계층에 의존하지 않도록 하여, 계층 간의 결합도를 최소화하고 유연하고 확장 가능한 구조를 구현하였습니다. +- 외부 모듈의 영향을 최소화하기 위해 도메인 모델에 JPA를 직접 결합하지 않고, POJO 객체를 사용하여 도메인의 순수성을 유지하였습니다. JPA 엔티티는 인프라 계층에서만 관리하여 도메인과 인프라를 명확하게 분리하였습니다. +- 조회는 DAO를 활용하여 CQRS 패턴을 적용, 데이터 조회와 변경을 명확히 분리하여 각 책임을 독립적으로 처리하고 성능과 유지보수성을 향상시켰습니다. diff --git a/build.gradle b/build.gradle index 5a629f1f7..086298cdc 100644 --- a/build.gradle +++ b/build.gradle @@ -23,17 +23,15 @@ repositories { mavenCentral() } -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} +apply from: "gradle/db.gradle" +apply from: "gradle/email.gradle" +apply from: "gradle/jwt.gradle" +apply from: "gradle/lombok.gradle" +apply from: "gradle/monitor.gradle" +apply from: "gradle/rabbitmq.gradle" +apply from: "gradle/spring.gradle" +apply from: "gradle/swagger.gradle" +apply from: "gradle/test.gradle" +apply from: "gradle/webflux.gradle" + -tasks.named('test') { - useJUnitPlatform() -} diff --git a/docker-compose-local.yml b/docker-compose-local.yml new file mode 100644 index 000000000..7321597fd --- /dev/null +++ b/docker-compose-local.yml @@ -0,0 +1,45 @@ +services: + cnergy-backend: + build: + context: . + dockerfile: Dockerfile-local + ports: + - '8080:8080' + environment: + SPRING_PROFILES_ACTIVE: local + depends_on: + - redis + networks: + - cnergy-backend-network + + redis: + image: redis:6.0.9 + ports: + - '6379:6379' + volumes: + - redis-data:/data + networks: + - cnergy-backend-network + + rabbit: + container_name: cnergy-rabbitmq + hostname: cnergy-rabbit + image: heidiks/rabbitmq-delayed-message-exchange:4.0.2-management + environment: + - RABBITMQ_DEFAULT_USER=admin + - RABBITMQ_DEFAULT_PASS=password + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq-data:/var/lib/rabbitmq + networks: + - cnergy-backend-network + +volumes: + redis-data: + rabbitmq-data: + +networks: + cnergy-backend-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..0afbdf0d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +services: + blue: + image: "${NCP_CONTAINER_REGISTRY}/cnergy-backend:${GITHUB_SHA}" + container_name: cnergy-backend-blue + env_file: + - .env + environment: + TZ: Asia/Seoul + ports: + - '8080:8080' + depends_on: + - redis + networks: + - cnergy-backend-network + + green: + image: "${NCP_CONTAINER_REGISTRY}/cnergy-backend:${GITHUB_SHA}" + container_name: cnergy-backend-green + env_file: + - .env + environment: + TZ: Asia/Seoul + ports: + - '8081:8080' + depends_on: + - redis + networks: + - cnergy-backend-network + + redis: + image: redis:6.0.9 + container_name: redis + ports: + - '6379:6379' + volumes: + - redis-data:/data + networks: + - cnergy-backend-network + + rabbit: + container_name: cnergy-rabbitmq + hostname: cnergy-rabbit + image: heidiks/rabbitmq-delayed-message-exchange:4.0.2-management + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + ports: + - "5672:5672" + - "15672:15672" + env_file: + - .env + volumes: + - rabbitmq-data:/var/lib/rabbitmq + networks: + - cnergy-backend-network + +volumes: + redis-data: + rabbitmq-data: + +networks: + cnergy-backend-network: + driver: bridge diff --git a/gradle/db.gradle b/gradle/db.gradle new file mode 100644 index 000000000..d644a3d38 --- /dev/null +++ b/gradle/db.gradle @@ -0,0 +1,11 @@ +dependencies { + // DB, JPA + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.maxmind.geoip2:geoip2:4.1.0' + + testImplementation 'com.h2database:h2' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' +} diff --git a/gradle/email.gradle b/gradle/email.gradle new file mode 100644 index 000000000..8445cba75 --- /dev/null +++ b/gradle/email.gradle @@ -0,0 +1,4 @@ +dependencies { + // Email + implementation 'org.springframework.boot:spring-boot-starter-mail' +} diff --git a/gradle/jwt.gradle b/gradle/jwt.gradle new file mode 100644 index 000000000..60dfb9bf9 --- /dev/null +++ b/gradle/jwt.gradle @@ -0,0 +1,6 @@ +dependencies { + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' +} diff --git a/gradle/lombok.gradle b/gradle/lombok.gradle new file mode 100644 index 000000000..1b976a52f --- /dev/null +++ b/gradle/lombok.gradle @@ -0,0 +1,5 @@ +dependencies { + // Lombok (code simplification) + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' +} diff --git a/gradle/monitor.gradle b/gradle/monitor.gradle new file mode 100644 index 000000000..d9592baab --- /dev/null +++ b/gradle/monitor.gradle @@ -0,0 +1,5 @@ +dependencies { + // monitoring (Prometheus) + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' +} diff --git a/gradle/rabbitmq.gradle b/gradle/rabbitmq.gradle new file mode 100644 index 000000000..f42c6e728 --- /dev/null +++ b/gradle/rabbitmq.gradle @@ -0,0 +1,5 @@ +dependencies { + // Rabbit MQ + implementation 'org.springframework.boot:spring-boot-starter-amqp' + testImplementation 'org.springframework.amqp:spring-rabbit-test' +} diff --git a/gradle/spring.gradle b/gradle/spring.gradle new file mode 100644 index 000000000..ed595f552 --- /dev/null +++ b/gradle/spring.gradle @@ -0,0 +1,25 @@ +dependencies { + // WEB + implementation 'org.springframework.boot:spring-boot-starter-web' + + // Development tools (for local development only) + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + // Spring Batch + implementation 'org.springframework.boot:spring-boot-starter-batch' + testImplementation 'org.springframework.batch:spring-batch-test' + + // Thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // Validation (for request/response validation) + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // WebSocket (for real-time communication) + implementation 'org.springframework.boot:spring-boot-starter-websocket' + + // Sentry + implementation 'io.sentry:sentry-spring-boot-starter-jakarta:7.17.0' + + +} diff --git a/gradle/swagger.gradle b/gradle/swagger.gradle new file mode 100644 index 000000000..bf3507d37 --- /dev/null +++ b/gradle/swagger.gradle @@ -0,0 +1,4 @@ +dependencies { + // Swagger-UI + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' +} diff --git a/gradle/test.gradle b/gradle/test.gradle new file mode 100644 index 000000000..bc787725e --- /dev/null +++ b/gradle/test.gradle @@ -0,0 +1,10 @@ +dependencies { + // Testing + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/webflux.gradle b/gradle/webflux.gradle new file mode 100644 index 000000000..261eda978 --- /dev/null +++ b/gradle/webflux.gradle @@ -0,0 +1,7 @@ +dependencies { + // WebFlux 외부 API 호출 + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Netty + implementation "io.netty:netty-resolver-dns-native-macos:4.1.113.Final:osx-aarch_64" +} diff --git a/src/main/java/spring/backend/BackendApplication.java b/src/main/java/spring/backend/BackendApplication.java index 5b9288112..698b1e56f 100644 --- a/src/main/java/spring/backend/BackendApplication.java +++ b/src/main/java/spring/backend/BackendApplication.java @@ -2,7 +2,11 @@ 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; +@EnableScheduling +@EnableJpaAuditing @SpringBootApplication public class BackendApplication { diff --git a/src/main/java/spring/backend/activity/application/QuickStartActivitySelectService.java b/src/main/java/spring/backend/activity/application/QuickStartActivitySelectService.java new file mode 100644 index 000000000..069d1de86 --- /dev/null +++ b/src/main/java/spring/backend/activity/application/QuickStartActivitySelectService.java @@ -0,0 +1,48 @@ +package spring.backend.activity.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.repository.ActivityRepository; +import spring.backend.quickstart.domain.repository.QuickStartRepository; +import spring.backend.activity.domain.service.FinishActivityAutoService; +import spring.backend.activity.presentation.dto.request.QuickStartActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.QuickStartActivitySelectResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.member.domain.entity.Member; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class QuickStartActivitySelectService { + private final ActivityRepository activityRepository; + private final QuickStartRepository quickStartRepository; + private final FinishActivityAutoService finishActivityAutoService; + + public QuickStartActivitySelectResponse quickStartUserActivitySelect(Member member, Long quickStartId, QuickStartActivitySelectRequest quickStartActivitySelectRequest) { + validateQuickStart(quickStartId); + validateRequest(quickStartActivitySelectRequest); + Activity activity = Activity.create(member.getId(), quickStartId, quickStartActivitySelectRequest.spareTime(), quickStartActivitySelectRequest.type(), quickStartActivitySelectRequest.keyword(), quickStartActivitySelectRequest.title(), quickStartActivitySelectRequest.content(), quickStartActivitySelectRequest.location()); + Activity savedActivity = activityRepository.save(activity); + finishActivityAutoService.finishActivityAuto(savedActivity); + return new QuickStartActivitySelectResponse(savedActivity.getId(), savedActivity.getTitle(), savedActivity.getKeyword()); + } + + private void validateQuickStart(Long quickStartId) { + if (quickStartRepository.findById(quickStartId) == null) { + log.error("[QuickStartActivitySelectRequest] Invalid quickStartId."); + throw QuickStartErrorCode.NOT_EXIST_QUICK_START.toException(); + } + } + + private void validateRequest(QuickStartActivitySelectRequest quickStartActivitySelectRequest) { + if (quickStartActivitySelectRequest == null) { + log.error("[QuickStartActivitySelectRequest] Invalid request."); + throw ActivityErrorCode.NOT_EXIST_ACTIVITY_CONDITION.toException(); + } + } +} diff --git a/src/main/java/spring/backend/activity/application/ReadActivitiesByMemberAndKeywordInMonthService.java b/src/main/java/spring/backend/activity/application/ReadActivitiesByMemberAndKeywordInMonthService.java new file mode 100644 index 000000000..7f9055773 --- /dev/null +++ b/src/main/java/spring/backend/activity/application/ReadActivitiesByMemberAndKeywordInMonthService.java @@ -0,0 +1,40 @@ +package spring.backend.activity.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.presentation.dto.response.ActivitiesByMemberAndKeywordInMonthResponse; +import spring.backend.activity.dto.response.ActivityWithTitleAndSavedTimeResponse; +import spring.backend.activity.dto.response.TotalSavedTimeAndActivityCountByKeywordInMonth; +import spring.backend.activity.query.dao.ActivityDao; +import spring.backend.core.converter.ImageConverter; +import spring.backend.core.util.TimeUtil; +import spring.backend.member.domain.entity.Member; + +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReadActivitiesByMemberAndKeywordInMonthService { + private final ActivityDao activityDao; + private final ImageConverter imageConverter; + + public ActivitiesByMemberAndKeywordInMonthResponse readActivitiesByMemberAndKeywordInMonth(Member member, int year, int month, Keyword.Category keywordCategory) { + YearMonth yearMonth = YearMonth.of(year, month); + LocalDateTime firstDayOfMonth = TimeUtil.toStartDayOfMonth(yearMonth); + LocalDateTime endDayOfMonth = TimeUtil.toEndDayOfMonth(yearMonth); + List activities = activityDao.findActivitiesByMemberAndKeywordInMonth(member.getId(), firstDayOfMonth, endDayOfMonth, keywordCategory); + TotalSavedTimeAndActivityCountByKeywordInMonth totalSavedTimeAndActivityCountByKeywordInMonth = activityDao.findTotalSavedTimeAndActivityCountByKeywordInMonth(member.getId(), firstDayOfMonth, endDayOfMonth, keywordCategory); + Keyword keyword = Keyword.create(keywordCategory, imageConverter.convertToImageUrl(keywordCategory)); + return new ActivitiesByMemberAndKeywordInMonthResponse( + totalSavedTimeAndActivityCountByKeywordInMonth.totalSavedTimeByKeywordInMonth(), + totalSavedTimeAndActivityCountByKeywordInMonth.totalActivityCountByKeywordInMonth(), + activities, + keyword + ); + } +} diff --git a/src/main/java/spring/backend/activity/application/ReadMonthlyActivityOverviewService.java b/src/main/java/spring/backend/activity/application/ReadMonthlyActivityOverviewService.java new file mode 100644 index 000000000..55e92449e --- /dev/null +++ b/src/main/java/spring/backend/activity/application/ReadMonthlyActivityOverviewService.java @@ -0,0 +1,48 @@ +package spring.backend.activity.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.activity.domain.value.Keyword.Category; +import spring.backend.activity.presentation.dto.request.MonthlyActivityOverviewRequest; +import spring.backend.activity.dto.response.MonthlyActivityCountByKeywordResponse; +import spring.backend.activity.presentation.dto.response.MonthlyActivityOverviewResponse; +import spring.backend.activity.dto.response.MonthlySavedTimeAndActivityCountResponse; +import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue; +import spring.backend.activity.query.dao.ActivityDao; +import spring.backend.core.converter.ImageConverter; +import spring.backend.core.util.TimeUtil; +import spring.backend.member.domain.entity.Member; + +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional(readOnly = true) +public class ReadMonthlyActivityOverviewService { + + private final ActivityDao activityDao; + + private final ImageConverter imageConverter; + + public MonthlyActivityOverviewResponse readMonthlyActivityOverview(Member member, MonthlyActivityOverviewRequest monthlyActivityOverviewRequest) { + YearMonth yearMonth = YearMonth.of(monthlyActivityOverviewRequest.year(), monthlyActivityOverviewRequest.month()); + LocalDateTime startDayOfMonth = TimeUtil.toStartDayOfMonth(yearMonth); + LocalDateTime endDayOfMonth = TimeUtil.toEndDayOfMonth(yearMonth); + MonthlySavedTimeAndActivityCountResponse monthlySavedTimeAndActivityCountResponse = activityDao.findMonthlyTotalSavedTimeAndTotalCount(member.getId(), startDayOfMonth, endDayOfMonth); + List activityByKeywordSummaryResponses = activityDao.findMonthlyActivitiesByKeywordSummary(member.getId(), startDayOfMonth, endDayOfMonth); + List updatedActivityByKeywordSummaryResponses = activityByKeywordSummaryResponses.stream() + .map(response -> { + Category category = response.keyword().getCategory(); + String imageUrl = imageConverter.convertToTransparent30ImageUrl(category); + KeywordJpaValue updatedKeyword = KeywordJpaValue.create(category, imageUrl); + return new MonthlyActivityCountByKeywordResponse(updatedKeyword, response.activityCount()); + }) + .toList(); + return new MonthlyActivityOverviewResponse(member.getUpdatedAt().getYear(), member.getUpdatedAt().getMonth(), monthlySavedTimeAndActivityCountResponse, updatedActivityByKeywordSummaryResponses); + } +} diff --git a/src/main/java/spring/backend/activity/application/UserActivitySelectService.java b/src/main/java/spring/backend/activity/application/UserActivitySelectService.java new file mode 100644 index 000000000..b018220af --- /dev/null +++ b/src/main/java/spring/backend/activity/application/UserActivitySelectService.java @@ -0,0 +1,39 @@ +package spring.backend.activity.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.repository.ActivityRepository; +import spring.backend.activity.domain.service.FinishActivityAutoService; +import spring.backend.activity.presentation.dto.request.UserActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.UserActivitySelectResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.member.domain.entity.Member; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class UserActivitySelectService { + + private final ActivityRepository activityRepository; + + private final FinishActivityAutoService finishActivityAutoService; + + public UserActivitySelectResponse userActivitySelect(Member member, UserActivitySelectRequest userActivitySelectRequest) { + validateRequest(userActivitySelectRequest); + Activity activity = Activity.create(member.getId(), null, userActivitySelectRequest.spareTime(), userActivitySelectRequest.type(), userActivitySelectRequest.keyword(), userActivitySelectRequest.title(), userActivitySelectRequest.content(), userActivitySelectRequest.location()); + Activity savedActivity = activityRepository.save(activity); + finishActivityAutoService.finishActivityAuto(savedActivity); + return new UserActivitySelectResponse(savedActivity.getId(), savedActivity.getTitle(), savedActivity.getKeyword()); + } + + private void validateRequest(UserActivitySelectRequest userActivitySelectRequest) { + if (userActivitySelectRequest == null) { + log.error("[UserActivitySelectRequest] Invalid request."); + throw ActivityErrorCode.NOT_EXIST_ACTIVITY_CONDITION.toException(); + } + } +} diff --git a/src/main/java/spring/backend/activity/domain/entity/Activity.java b/src/main/java/spring/backend/activity/domain/entity/Activity.java new file mode 100644 index 000000000..0e063fcf3 --- /dev/null +++ b/src/main/java/spring/backend/activity/domain/entity/Activity.java @@ -0,0 +1,89 @@ +package spring.backend.activity.domain.entity; + +import lombok.Builder; +import lombok.Getter; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; +import spring.backend.activity.exception.ActivityErrorCode; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +@Getter +@Builder +public class Activity { + + public static final Integer MAX_SAVED_TIME = 300; + + private Long id; + + private UUID memberId; + + private Long quickStartId; + + private Integer spareTime; + + private Type type; + + private Keyword keyword; + + private String title; + + private String content; + + private String location; + + @Builder.Default + private Boolean finished = false; + + private LocalDateTime finishedAt; + + private Integer savedTime; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private Boolean deleted; + + public void validateActivityOwner(UUID memberId) { + if (!this.memberId.equals(memberId)) { + throw ActivityErrorCode.MEMBER_ID_MISMATCH.toException(); + } + } + + public boolean isFinished() { + return finished != null && finished; + } + + public void finish() { + if (isFinished()) { + throw ActivityErrorCode.ALREADY_FINISHED_ACTIVITY.toException(); + } + finished = true; + finishedAt = LocalDateTime.now(); + savedTime = calculateSavedTime(); + } + + private Integer calculateSavedTime() { + long savedTime = ChronoUnit.MINUTES.between(createdAt, finishedAt); + if (savedTime < 0 || savedTime > MAX_SAVED_TIME) { + throw ActivityErrorCode.INVALID_ACTIVITY_DURATION.toException(); + } + return Math.toIntExact(savedTime); + } + + public static Activity create(UUID memberId, Long quickStartId, Integer spareTime, Type type, Keyword keyword, String title, String content, String location) { + return Activity.builder() + .memberId(memberId) + .quickStartId(quickStartId) + .spareTime(spareTime) + .type(type) + .keyword(keyword) + .title(title) + .content(content) + .location(location) + .build(); + } +} diff --git a/src/main/java/spring/backend/activity/domain/repository/ActivityRepository.java b/src/main/java/spring/backend/activity/domain/repository/ActivityRepository.java new file mode 100644 index 000000000..3226141d9 --- /dev/null +++ b/src/main/java/spring/backend/activity/domain/repository/ActivityRepository.java @@ -0,0 +1,9 @@ +package spring.backend.activity.domain.repository; + +import spring.backend.activity.domain.entity.Activity; + +public interface ActivityRepository { + + Activity findById(Long id); + Activity save(Activity Activity); +} diff --git a/src/main/java/spring/backend/activity/domain/service/FinishActivityAutoService.java b/src/main/java/spring/backend/activity/domain/service/FinishActivityAutoService.java new file mode 100644 index 000000000..08c0572a8 --- /dev/null +++ b/src/main/java/spring/backend/activity/domain/service/FinishActivityAutoService.java @@ -0,0 +1,28 @@ +package spring.backend.activity.domain.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.infrastructure.queue.FinishActivityMessage; +import spring.backend.activity.infrastructure.queue.FinishActivityMessageProducer; +import spring.backend.core.configuration.property.queue.FinishActivityQueueProperty; + +@Service +@RequiredArgsConstructor +public class FinishActivityAutoService { + + private final FinishActivityQueueProperty finishActivityQueueProperty; + + public void finishActivityAuto(Activity activity) { + int spareTime = activity.getSpareTime(); + FinishActivityMessageProducer finishActivityMessageProducer = new FinishActivityMessageProducer( + finishActivityQueueProperty, + FinishActivityMessage.builder() + .activityId(activity.getId()) + .spareTime(spareTime) + .build() + ); + long delayTime = (long) spareTime * 60 * 1000; + finishActivityMessageProducer.publishMessageWithDelay(delayTime); + } +} diff --git a/src/main/java/spring/backend/activity/domain/service/FinishActivityService.java b/src/main/java/spring/backend/activity/domain/service/FinishActivityService.java new file mode 100644 index 000000000..18ce23de5 --- /dev/null +++ b/src/main/java/spring/backend/activity/domain/service/FinishActivityService.java @@ -0,0 +1,44 @@ +package spring.backend.activity.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.repository.ActivityRepository; +import spring.backend.activity.dto.response.ActivityInfo; +import spring.backend.activity.presentation.dto.response.FinishActivityResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.dto.response.HomeMemberInfoResponse; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class FinishActivityService { + + private final ActivityRepository activityRepository; + + public FinishActivityResponse finishActivity(Member member, Long activityId) { + Activity activity = activityRepository.findById(activityId); + validateActivity(activity, member); + activity.finish(); + activityRepository.save(activity); + return toResponse(member, activity); + } + + private void validateActivity(Activity activity, Member member) { + if (activity == null) { + log.error("[FinishActivityService.validateActivity] activity is null"); + throw ActivityErrorCode.NOT_EXIST_ACTIVITY.toException(); + } + activity.validateActivityOwner(member.getId()); + } + + private FinishActivityResponse toResponse(Member member, Activity activity) { + HomeMemberInfoResponse memberInfo = HomeMemberInfoResponse.from(member); + ActivityInfo activityInfo = ActivityInfo.from(activity); + return new FinishActivityResponse(memberInfo, activityInfo); + } +} diff --git a/src/main/java/spring/backend/activity/domain/value/Keyword.java b/src/main/java/spring/backend/activity/domain/value/Keyword.java new file mode 100644 index 000000000..72f9bf248 --- /dev/null +++ b/src/main/java/spring/backend/activity/domain/value/Keyword.java @@ -0,0 +1,44 @@ +package spring.backend.activity.domain.value; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@EqualsAndHashCode +public class Keyword { + + private final Category category; + + private final String image; + + @Getter + @RequiredArgsConstructor + public enum Category { + SELF_DEVELOPMENT("자기개발"), + HEALTH("건강"), + CULTURE_ART("문화/예술"), + ENTERTAINMENT("엔터테인먼트"), + RELAXATION("휴식"), + SOCIAL("소셜"); + + private final String description; + + private static final Map DESCRIPTION_TO_CATEGORY_MAP = Arrays.stream(Category.values()) + .collect(Collectors.toMap(Category::getDescription, category -> category)); + + public static Category from(String description) { + return DESCRIPTION_TO_CATEGORY_MAP.get(description); + } + } + + public static Keyword create(Category category, String image) { + return new Keyword(category, image); + } +} diff --git a/src/main/java/spring/backend/activity/domain/value/Type.java b/src/main/java/spring/backend/activity/domain/value/Type.java new file mode 100644 index 000000000..71d742a37 --- /dev/null +++ b/src/main/java/spring/backend/activity/domain/value/Type.java @@ -0,0 +1,22 @@ +package spring.backend.activity.domain.value; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Type { + ONLINE("온라인"), + OFFLINE("오프라인"), + ONLINE_AND_OFFLINE("온라인과 오프라인 모두"); + + private final String description; + + public boolean includesOffline() { + return this == OFFLINE || this == ONLINE_AND_OFFLINE; + } + + public boolean includesOnline() { + return this == ONLINE || this == ONLINE_AND_OFFLINE; + } +} diff --git a/src/main/java/spring/backend/activity/dto/response/ActivityInfo.java b/src/main/java/spring/backend/activity/dto/response/ActivityInfo.java new file mode 100644 index 000000000..38e7eb586 --- /dev/null +++ b/src/main/java/spring/backend/activity/dto/response/ActivityInfo.java @@ -0,0 +1,27 @@ +package spring.backend.activity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.value.Keyword; + +public record ActivityInfo( + + @Schema(description = "활동 ID", example = "1") + Long id, + + @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}") + Keyword keyword, + + @Schema(description = "활동 제목", example = "휴식에는 역시 명상이 최고!") + String title, + + @Schema(description = "활동 내용", example = "마음의 편안을 가져다주는 명상음악 20분 듣기") + String content, + + @Schema(description = "모은 시간", example = "20") + Integer savedTime +) { + public static ActivityInfo from(Activity activity) { + return new ActivityInfo(activity.getId(), activity.getKeyword(), activity.getTitle(), activity.getContent(), activity.getSavedTime()); + } +} diff --git a/src/main/java/spring/backend/activity/dto/response/ActivityWithTitleAndSavedTimeResponse.java b/src/main/java/spring/backend/activity/dto/response/ActivityWithTitleAndSavedTimeResponse.java new file mode 100644 index 000000000..a04c3574f --- /dev/null +++ b/src/main/java/spring/backend/activity/dto/response/ActivityWithTitleAndSavedTimeResponse.java @@ -0,0 +1,17 @@ +package spring.backend.activity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +public record ActivityWithTitleAndSavedTimeResponse( + @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기") + String title, + + @Schema(description = "모은 시간", example = "60") + long savedTime, + + @Schema(description = "활동 날짜", example = "2021-07-01T00:00:00") + LocalDateTime dateOfActivity +) { +} diff --git a/src/main/java/spring/backend/activity/dto/response/HomeActivityInfoResponse.java b/src/main/java/spring/backend/activity/dto/response/HomeActivityInfoResponse.java new file mode 100644 index 000000000..7e356239d --- /dev/null +++ b/src/main/java/spring/backend/activity/dto/response/HomeActivityInfoResponse.java @@ -0,0 +1,20 @@ +package spring.backend.activity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue; + +public record HomeActivityInfoResponse( + + @Schema(description = "활동 ID", example = "1") + Long id, + + @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}") + KeywordJpaValue keyword, + + @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기") + String title, + + @Schema(description = "모은 시간", example = "60") + Integer savedTime +) { +} diff --git a/src/main/java/spring/backend/activity/dto/response/MonthlyActivityCountByKeywordResponse.java b/src/main/java/spring/backend/activity/dto/response/MonthlyActivityCountByKeywordResponse.java new file mode 100644 index 000000000..367b27f8c --- /dev/null +++ b/src/main/java/spring/backend/activity/dto/response/MonthlyActivityCountByKeywordResponse.java @@ -0,0 +1,13 @@ +package spring.backend.activity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue; + +public record MonthlyActivityCountByKeywordResponse( + @Schema(description = "활동의 Keyword" , example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}") + KeywordJpaValue keyword, + + @Schema(description = "Keyword별 활동 횟수" , example = "2") + long activityCount +) { +} diff --git a/src/main/java/spring/backend/activity/dto/response/MonthlySavedTimeAndActivityCountResponse.java b/src/main/java/spring/backend/activity/dto/response/MonthlySavedTimeAndActivityCountResponse.java new file mode 100644 index 000000000..ff02a316f --- /dev/null +++ b/src/main/java/spring/backend/activity/dto/response/MonthlySavedTimeAndActivityCountResponse.java @@ -0,0 +1,12 @@ +package spring.backend.activity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MonthlySavedTimeAndActivityCountResponse( + @Schema(description = "이번 달 총 모은 자투리 시간(분단위)", example = "120") + long monthlyTotalSavedTime, + + @Schema(description = "이번 달 총 활동 횟수", example = "2") + long monthlyTotalActivityCount +) { +} diff --git a/src/main/java/spring/backend/activity/dto/response/TotalSavedTimeAndActivityCountByKeywordInMonth.java b/src/main/java/spring/backend/activity/dto/response/TotalSavedTimeAndActivityCountByKeywordInMonth.java new file mode 100644 index 000000000..dd1ef6927 --- /dev/null +++ b/src/main/java/spring/backend/activity/dto/response/TotalSavedTimeAndActivityCountByKeywordInMonth.java @@ -0,0 +1,12 @@ +package spring.backend.activity.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TotalSavedTimeAndActivityCountByKeywordInMonth( + @Schema(description = "이번 달 해당 키워드 활동을 통해 모은 자투리 시간(분단위)", example = "120") + long totalSavedTimeByKeywordInMonth, + + @Schema(description = "활동 키워드별 활동 총 개수", example = "12") + long totalActivityCountByKeywordInMonth +) { +} diff --git a/src/main/java/spring/backend/activity/exception/ActivityErrorCode.java b/src/main/java/spring/backend/activity/exception/ActivityErrorCode.java new file mode 100644 index 000000000..e5756d5e5 --- /dev/null +++ b/src/main/java/spring/backend/activity/exception/ActivityErrorCode.java @@ -0,0 +1,30 @@ +package spring.backend.activity.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum ActivityErrorCode implements BaseErrorCode { + + NOT_EXIST_LOCATION_WHEN_OFFLINE(HttpStatus.BAD_REQUEST, "오프라인의 경우 위치 정보가 필수입니다."), + EXIST_LOCATION_WHEN_ONLINE(HttpStatus.BAD_REQUEST, "온라인의 경우 위치 정보를 포함하지 않습니다."), + ACTIVITY_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "활동을 저장하는데 실패하였습니다."), + NOT_EXIST_ACTIVITY(HttpStatus.BAD_REQUEST, "활동이 존재하지 않습니다."), + MEMBER_ID_MISMATCH(HttpStatus.FORBIDDEN, "활동과 멤버 ID가 일치하지 않습니다."), + INVALID_ACTIVITY_DURATION(HttpStatus.BAD_REQUEST, "활동 지속 시간이 허용된 범위를 초과했습니다."), + ALREADY_FINISHED_ACTIVITY(HttpStatus.BAD_REQUEST, "이미 종료된 활동입니다."), + NOT_EXIST_ACTIVITY_CONDITION(HttpStatus.BAD_REQUEST, "요청이 비어있습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/activity/infrastructure/mapper/ActivityMapper.java b/src/main/java/spring/backend/activity/infrastructure/mapper/ActivityMapper.java new file mode 100644 index 000000000..04df3dae6 --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/mapper/ActivityMapper.java @@ -0,0 +1,61 @@ +package spring.backend.activity.infrastructure.mapper; + +import org.springframework.stereotype.Component; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity; +import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue; + +import java.util.Optional; + +@Component +public class ActivityMapper { + + public Activity toDomainEntity(ActivityJpaEntity activity) { + return Activity.builder() + .id(activity.getId()) + .memberId(activity.getMemberId()) + .quickStartId(activity.getQuickStartId()) + .spareTime(activity.getSpareTime()) + .type(activity.getType()) + .keyword(toDomainValue(activity.getKeyword())) + .title(activity.getTitle()) + .content(activity.getContent()) + .location(activity.getLocation()) + .finished(activity.getFinished()) + .finishedAt(activity.getFinishedAt()) + .savedTime(activity.getSavedTime()) + .createdAt(activity.getCreatedAt()) + .updatedAt(activity.getUpdatedAt()) + .deleted(activity.getDeleted()) + .build(); + } + + public ActivityJpaEntity toJpaEntity(Activity activity) { + return ActivityJpaEntity.builder() + .id(activity.getId()) + .memberId(activity.getMemberId()) + .quickStartId(activity.getQuickStartId()) + .spareTime(activity.getSpareTime()) + .type(activity.getType()) + .keyword(toJpaValue(activity.getKeyword())) + .title(activity.getTitle()) + .content(activity.getContent()) + .location(activity.getLocation()) + .finished(activity.getFinished()) + .finishedAt(activity.getFinishedAt()) + .savedTime(activity.getSavedTime()) + .createdAt(activity.getCreatedAt()) + .updatedAt(activity.getUpdatedAt()) + .deleted(Optional.ofNullable(activity.getDeleted()).orElse(false)) + .build(); + } + + private Keyword toDomainValue(KeywordJpaValue keywordJpaValue) { + return Keyword.create(keywordJpaValue.getCategory(), keywordJpaValue.getImage()); + } + + private KeywordJpaValue toJpaValue(Keyword keyword) { + return KeywordJpaValue.create(keyword.getCategory(), keyword.getImage()); + } +} diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/adapter/ActivityRepositoryImpl.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/adapter/ActivityRepositoryImpl.java new file mode 100644 index 000000000..884b64298 --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/adapter/ActivityRepositoryImpl.java @@ -0,0 +1,41 @@ +package spring.backend.activity.infrastructure.persistence.jpa.adapter; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Repository; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.repository.ActivityRepository; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.activity.infrastructure.mapper.ActivityMapper; +import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity; +import spring.backend.activity.infrastructure.persistence.jpa.repository.ActivityJpaRepository; + +@Repository +@RequiredArgsConstructor +@Log4j2 +public class ActivityRepositoryImpl implements ActivityRepository { + + private final ActivityMapper activityMapper; + private final ActivityJpaRepository activityJpaRepository; + + @Override + public Activity findById(Long id) { + ActivityJpaEntity activityJpaEntity = activityJpaRepository.findById(id).orElse(null); + if (activityJpaEntity == null) { + return null; + } + return activityMapper.toDomainEntity(activityJpaEntity); + } + + @Override + public Activity save(Activity activity) { + try { + ActivityJpaEntity activityJpaEntity = activityMapper.toJpaEntity(activity); + activityJpaRepository.save(activityJpaEntity); + return activityMapper.toDomainEntity(activityJpaEntity); + } catch (Exception e) { + log.error("[ActivityRepositoryImpl] Failed to save activity", e); + throw ActivityErrorCode.ACTIVITY_SAVE_FAILED.toException(); + } + } +} diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/dao/ActivityJpaDao.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/dao/ActivityJpaDao.java new file mode 100644 index 000000000..65b79d208 --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/dao/ActivityJpaDao.java @@ -0,0 +1,127 @@ +package spring.backend.activity.infrastructure.persistence.jpa.dao; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.dto.response.*; +import spring.backend.activity.dto.response.HomeActivityInfoResponse; +import spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail; +import spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary; +import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity; +import spring.backend.activity.query.dao.ActivityDao; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public interface ActivityJpaDao extends JpaRepository, ActivityDao { + + @Override + @Query(""" + select new spring.backend.activity.dto.response.HomeActivityInfoResponse( + a.id, + a.keyword, + a.title, + a.savedTime + ) + from ActivityJpaEntity a + where a.memberId = :memberId + and a.createdAt between :startDateTime and :endDateTime + and a.finished = true + order by a.createdAt ASC + """) + List findTodayActivities(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + @Override + @Query(""" + select new spring.backend.activity.dto.response.MonthlySavedTimeAndActivityCountResponse( + coalesce(sum(a.savedTime), 0), + coalesce(count(a), 0) + ) + from ActivityJpaEntity a + where a.memberId = :memberId + and a.createdAt between :startDateTime and :endDateTime + and a.finished = true + """) + MonthlySavedTimeAndActivityCountResponse findMonthlyTotalSavedTimeAndTotalCount(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + @Override + @Query(""" + select new spring.backend.activity.dto.response.MonthlyActivityCountByKeywordResponse( + a.keyword, + coalesce(count(a), 0) + ) + from ActivityJpaEntity a + where a.memberId = :memberId + and a.createdAt between :startDateTime and :endDateTime + and a.finished = true + group by a.keyword + order by count (a) desc + """) + List findMonthlyActivitiesByKeywordSummary(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + @Override + @Query(""" + select new spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary( + m.createdAt, + coalesce(sum(a.savedTime), 0), + count(a) + ) + from MemberJpaEntity m + left join ActivityJpaEntity a on a.memberId = m.id + and a.finished = true + and function('year', a.createdAt) = :year + and function('month', a.createdAt) = :month + where m.id = :memberId + """) + UserMonthlyActivitySummary findActivitySummaryByYearAndMonth(UUID memberId, int year, int month); + + + @Override + @Query(""" + select new spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail( + a.keyword.category, + a.title, + a.savedTime, + a.createdAt + ) + from ActivityJpaEntity a + where a.memberId = :memberId + and a.finished = true + and function('year', a.createdAt) = :year + and function('month', a.createdAt) = :month + order by a.createdAt desc + """) + List findActivityDetailsByYearAndMonth(UUID memberId, int year, int month); + + @Override + @Query(""" + select new spring.backend.activity.dto.response.ActivityWithTitleAndSavedTimeResponse( + a.title, + coalesce(sum(a.savedTime), 0), + a.createdAt + ) + from ActivityJpaEntity a + where a.memberId = :memberId + and a.createdAt between :startDateTime and :endDateTime + and a.finished = true + and a.keyword.category = :keywordCategory + order by a.createdAt DESC + """) + List findActivitiesByMemberAndKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory); + + @Override + @Query(""" + select new spring.backend.activity.dto.response.TotalSavedTimeAndActivityCountByKeywordInMonth( + coalesce(sum(a.savedTime), 0), + coalesce(count(a), 0) + ) + from ActivityJpaEntity a + where a.memberId = :memberId + and a.createdAt between :startDateTime and :endDateTime + and a.finished = true + and a.keyword.category = :keywordCategory + """) + TotalSavedTimeAndActivityCountByKeywordInMonth findTotalSavedTimeAndActivityCountByKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory); + +} diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/entity/ActivityJpaEntity.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/entity/ActivityJpaEntity.java new file mode 100644 index 000000000..65d5b3ad1 --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/entity/ActivityJpaEntity.java @@ -0,0 +1,59 @@ +package spring.backend.activity.infrastructure.persistence.jpa.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import spring.backend.activity.infrastructure.persistence.jpa.value.KeywordJpaValue; +import spring.backend.activity.domain.value.Type; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.core.infrastructure.jpa.shared.BaseLongIdEntity; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "activity") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActivityJpaEntity extends BaseLongIdEntity { + + private UUID memberId; + + private Long quickStartId; + + private Integer spareTime; + + @Enumerated(EnumType.STRING) + private Type type; + + @Embedded + private KeywordJpaValue keyword; + + private String title; + + private String content; + + private String location; + + private Boolean finished; + + private LocalDateTime finishedAt; + + private Integer savedTime; + + public boolean isFinished() { + return finished != null && finished; + } + + public void finish() { + if (isFinished()) { + throw ActivityErrorCode.ALREADY_FINISHED_ACTIVITY.toException(); + } + finished = true; + finishedAt = LocalDateTime.now(); + savedTime = spareTime; + } +} diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/repository/ActivityJpaRepository.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/repository/ActivityJpaRepository.java new file mode 100644 index 000000000..e882aab1d --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/repository/ActivityJpaRepository.java @@ -0,0 +1,7 @@ +package spring.backend.activity.infrastructure.persistence.jpa.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity; + +public interface ActivityJpaRepository extends JpaRepository { +} diff --git a/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/value/KeywordJpaValue.java b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/value/KeywordJpaValue.java new file mode 100644 index 000000000..8b20cd2ed --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/persistence/jpa/value/KeywordJpaValue.java @@ -0,0 +1,24 @@ +package spring.backend.activity.infrastructure.persistence.jpa.value; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.*; +import spring.backend.activity.domain.value.Keyword.Category; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EqualsAndHashCode +public class KeywordJpaValue { + + @Enumerated(EnumType.STRING) + private Category category; + + private String image; + + public static KeywordJpaValue create(Category category, String image) { + return new KeywordJpaValue(category, image); + } +} diff --git a/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessage.java b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessage.java new file mode 100644 index 000000000..56b927d80 --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessage.java @@ -0,0 +1,10 @@ +package spring.backend.activity.infrastructure.queue; + +import lombok.Builder; + +@Builder +public record FinishActivityMessage( + long activityId, + int spareTime +) { +} diff --git a/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageConsumer.java b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageConsumer.java new file mode 100644 index 000000000..cf97284ce --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageConsumer.java @@ -0,0 +1,35 @@ +package spring.backend.activity.infrastructure.queue; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.activity.infrastructure.persistence.jpa.entity.ActivityJpaEntity; +import spring.backend.activity.infrastructure.persistence.jpa.repository.ActivityJpaRepository; +import spring.backend.core.infrastructure.queue.MessageConsumer; + +@Component +@RequiredArgsConstructor +@Transactional +@Log4j2 +public class FinishActivityMessageConsumer implements MessageConsumer { + + private final ActivityJpaRepository activityJpaRepository; + + @Override + @RabbitListener(queues = "${finish-activity-queue.queue}") + public void consumeMessage(FinishActivityMessage message) { + try { + if (message == null) { + log.error("[FinishActivityMessageConsumer] Message is null"); + return; + } + ActivityJpaEntity activity = activityJpaRepository.findById(message.activityId()).orElseThrow(ActivityErrorCode.NOT_EXIST_ACTIVITY::toException); + activity.finish(); + } catch (Exception e) { + log.error("[FinishActivityMessageConsumer] Error processing message", e); + } + } +} diff --git a/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageProducer.java b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageProducer.java new file mode 100644 index 000000000..26d67fff0 --- /dev/null +++ b/src/main/java/spring/backend/activity/infrastructure/queue/FinishActivityMessageProducer.java @@ -0,0 +1,11 @@ +package spring.backend.activity.infrastructure.queue; + +import spring.backend.core.configuration.property.queue.FinishActivityQueueProperty; +import spring.backend.core.infrastructure.queue.MessageProducer; + +public class FinishActivityMessageProducer extends MessageProducer { + + public FinishActivityMessageProducer(FinishActivityQueueProperty queueProperty, FinishActivityMessage message) { + super(queueProperty, message); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/FinishActivityController.java b/src/main/java/spring/backend/activity/presentation/FinishActivityController.java new file mode 100644 index 000000000..a4728d98e --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/FinishActivityController.java @@ -0,0 +1,27 @@ +package spring.backend.activity.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.activity.domain.service.FinishActivityService; +import spring.backend.activity.presentation.dto.response.FinishActivityResponse; +import spring.backend.activity.presentation.swagger.FinishActivitySwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class FinishActivityController implements FinishActivitySwagger { + + private final FinishActivityService finishActivityService; + + @Authorization + @PatchMapping("/v1/activities/{activityId}/finish") + public ResponseEntity> finishActivity(@AuthorizedMember Member member, @PathVariable Long activityId) { + return ResponseEntity.ok(new RestResponse<>(finishActivityService.finishActivity(member, activityId))); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/QuickStartActivitySelectController.java b/src/main/java/spring/backend/activity/presentation/QuickStartActivitySelectController.java new file mode 100644 index 000000000..bd5198f41 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/QuickStartActivitySelectController.java @@ -0,0 +1,32 @@ +package spring.backend.activity.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.activity.application.QuickStartActivitySelectService; +import spring.backend.activity.presentation.dto.request.QuickStartActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.QuickStartActivitySelectResponse; +import spring.backend.activity.presentation.swagger.QuickStartActivitySelectSwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class QuickStartActivitySelectController implements QuickStartActivitySelectSwagger { + + private final QuickStartActivitySelectService quickStartActivitySelectService; + + @Override + @Authorization + @PostMapping("/v1/quick-starts/{quickStartId}/activities") + public ResponseEntity> quickStartUserActivitySelect(@AuthorizedMember Member member, @PathVariable Long quickStartId, @Valid @RequestBody QuickStartActivitySelectRequest quickStartActivitySelectRequest) { + QuickStartActivitySelectResponse savedActivityIdCreatedByQuickStartResponse = quickStartActivitySelectService.quickStartUserActivitySelect(member, quickStartId, quickStartActivitySelectRequest); + return ResponseEntity.ok(new RestResponse<>(savedActivityIdCreatedByQuickStartResponse)); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/ReadActivitiesByMemberAndKeywordInMonthController.java b/src/main/java/spring/backend/activity/presentation/ReadActivitiesByMemberAndKeywordInMonthController.java new file mode 100644 index 000000000..02eb1a424 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/ReadActivitiesByMemberAndKeywordInMonthController.java @@ -0,0 +1,37 @@ +package spring.backend.activity.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.activity.application.ReadActivitiesByMemberAndKeywordInMonthService; +import spring.backend.activity.presentation.dto.request.ActivitiesByMemberAndKeywordInMonthRequest; +import spring.backend.activity.presentation.dto.response.ActivitiesByMemberAndKeywordInMonthResponse; +import spring.backend.activity.presentation.swagger.ReadActivitiesByMemberAndKeywordInMonthSwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class ReadActivitiesByMemberAndKeywordInMonthController implements ReadActivitiesByMemberAndKeywordInMonthSwagger { + private final ReadActivitiesByMemberAndKeywordInMonthService readActivitiesByMemberAndKeywordInMonthService; + + @Authorization + @GetMapping("/v1/activities") + public ResponseEntity> readActivitiesByMemberAndKeywordInMonth( + @AuthorizedMember Member member, + @Valid ActivitiesByMemberAndKeywordInMonthRequest activitiesByMemberAndKeywordInMonthRequest + ) { + ActivitiesByMemberAndKeywordInMonthResponse activitiesByMemberAndKeywordInMonthResponse = readActivitiesByMemberAndKeywordInMonthService.readActivitiesByMemberAndKeywordInMonth( + member, + activitiesByMemberAndKeywordInMonthRequest.year(), + activitiesByMemberAndKeywordInMonthRequest.month(), + activitiesByMemberAndKeywordInMonthRequest.keywordCategory() + ); + + return ResponseEntity.ok(new RestResponse<>(activitiesByMemberAndKeywordInMonthResponse)); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/ReadActivityCalendarController.java b/src/main/java/spring/backend/activity/presentation/ReadActivityCalendarController.java new file mode 100644 index 000000000..b786b8b19 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/ReadActivityCalendarController.java @@ -0,0 +1,34 @@ +package spring.backend.activity.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.activity.presentation.dto.request.ReadActivityCalendarRequest; +import spring.backend.activity.presentation.dto.response.ActivityCalendarResponse; +import spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail; +import spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary; +import spring.backend.activity.presentation.swagger.ReadActivityCalendarSwagger; +import spring.backend.activity.query.dao.ActivityDao; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class ReadActivityCalendarController implements ReadActivityCalendarSwagger { + + private final ActivityDao activityDao; + + @Authorization + @GetMapping("/v1/activity-calendar") + public ResponseEntity> readActivityCalendar(@AuthorizedMember Member member, @Valid ReadActivityCalendarRequest request) { + UserMonthlyActivitySummary summary = activityDao.findActivitySummaryByYearAndMonth(member.getId(), request.year(), request.month()); + List details = activityDao.findActivityDetailsByYearAndMonth(member.getId(), request.year(), request.month()); + return ResponseEntity.ok(new RestResponse<>(ActivityCalendarResponse.of(summary, details))); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/ReadMonthlyActivityOverviewController.java b/src/main/java/spring/backend/activity/presentation/ReadMonthlyActivityOverviewController.java new file mode 100644 index 000000000..289340df6 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/ReadMonthlyActivityOverviewController.java @@ -0,0 +1,31 @@ +package spring.backend.activity.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.activity.application.ReadMonthlyActivityOverviewService; +import spring.backend.activity.presentation.dto.request.MonthlyActivityOverviewRequest; +import spring.backend.activity.presentation.dto.response.MonthlyActivityOverviewResponse; +import spring.backend.activity.presentation.swagger.ReadMonthlyActivityOverviewSwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class ReadMonthlyActivityOverviewController implements ReadMonthlyActivityOverviewSwagger { + private final ReadMonthlyActivityOverviewService readMonthlyActivityOverviewService; + + @Authorization + @GetMapping("/v1/activities/overview") + public ResponseEntity> readMonthlyActivityOverview( + @AuthorizedMember Member member, + @Valid MonthlyActivityOverviewRequest monthlyActivityOverviewRequest + ) { + MonthlyActivityOverviewResponse monthlyActivityOverviewResponse = readMonthlyActivityOverviewService.readMonthlyActivityOverview(member, monthlyActivityOverviewRequest); + return ResponseEntity.ok(new RestResponse<>(monthlyActivityOverviewResponse)); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/UserActivitySelectController.java b/src/main/java/spring/backend/activity/presentation/UserActivitySelectController.java new file mode 100644 index 000000000..028781c62 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/UserActivitySelectController.java @@ -0,0 +1,31 @@ +package spring.backend.activity.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.activity.application.UserActivitySelectService; +import spring.backend.activity.presentation.dto.request.UserActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.UserActivitySelectResponse; +import spring.backend.activity.presentation.swagger.UserActivitySelectSwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class UserActivitySelectController implements UserActivitySelectSwagger { + + private final UserActivitySelectService userActivitySelectService; + + @Authorization + @PostMapping("/v1/activities") + @Override + public ResponseEntity> userActivitySelect(@AuthorizedMember Member member, @Valid @RequestBody UserActivitySelectRequest userActivitySelectRequest) { + UserActivitySelectResponse userActivitySelectResponse = userActivitySelectService.userActivitySelect(member, userActivitySelectRequest); + return ResponseEntity.ok(new RestResponse<>(userActivitySelectResponse)); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/ActivitiesByMemberAndKeywordInMonthRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/ActivitiesByMemberAndKeywordInMonthRequest.java new file mode 100644 index 000000000..931a8e7a2 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/request/ActivitiesByMemberAndKeywordInMonthRequest.java @@ -0,0 +1,24 @@ +package spring.backend.activity.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import spring.backend.activity.domain.value.Keyword; + +public record ActivitiesByMemberAndKeywordInMonthRequest( + @Min(value = 2024, message = "년도는 2024년 이후 값이어야 합니다.") + int year, + + @Min(value = 1, message = "월은 1월과 12월 사이 값이어야 합니다.") + @Max(value = 12, message = "월은 1월과 12월 사이 값이어야 합니다.") + int month, + + @NotNull(message = "키워드 카테고리는 필수 값입니다.") + @Schema(description = "키워드 카테고리 (예: HEALTH, SELF_DEVELOPMENT, CULTURE_ART, ENTERTAINMENT, RELAXATION, SOCIAL)", example = "RELAXATION", allowableValues = { + "SELF_DEVELOPMENT", "HEALTH", "CULTURE_ART", + "ENTERTAINMENT", "RELAXATION", "SOCIAL" + }) + Keyword.Category keywordCategory +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/MonthlyActivityOverviewRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/MonthlyActivityOverviewRequest.java new file mode 100644 index 000000000..902e0803c --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/request/MonthlyActivityOverviewRequest.java @@ -0,0 +1,14 @@ +package spring.backend.activity.presentation.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record MonthlyActivityOverviewRequest( + @Min(value = 2024, message = "년도는 2024년 이후 값이어야 합니다.") + int year, + + @Min(value = 1, message = "월은 1월과 12월 사이 값이어야 합니다.") + @Max(value = 12, message = "월은 1월과 12월 사이 값이어야 합니다.") + int month +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/QuickStartActivitySelectRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/QuickStartActivitySelectRequest.java new file mode 100644 index 000000000..d38de3560 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/request/QuickStartActivitySelectRequest.java @@ -0,0 +1,36 @@ +package spring.backend.activity.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; + +public record QuickStartActivitySelectRequest( + @NotNull(message = "활동 유형은 필수 입력 항목입니다.") + @Schema(description = "활동 유형 (ONLINE, OFFLINE, ONLINE_AND_OFFLINE)", example = "ONLINE") + Type type, + + @NotNull(message = "자투리 시간은 필수 입력 항목입니다.") + @Min(value = 10, message = "자투리 시간은 최소 10이어야 합니다.") + @Max(value = 300, message = "자투리 시간은 최대 300이어야 합니다.") + @Schema(description = "자투리 시간", example = "300") + Integer spareTime, + + @NotNull(message = "키워드는 필수 입력 항목입니다.") + @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}") + Keyword keyword, + + @NotNull(message = "타이틀은 필수 입력 항목입니다.") + @Schema(description = "타이틀", example = "카페에서 커피 마시며 책 읽기") + String title, + + @NotNull(message = "내용은 필수 입력 항목입니다.") + @Schema(description = "내용", example = "조용한 카페에서 좋아하는 책을 읽으며 여유로운 시간을 즐길 수 있습니다.") + String content, + + @Schema(description = "장소", example = "서울시 강남구 역삼동") + String location +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/ReadActivityCalendarRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/ReadActivityCalendarRequest.java new file mode 100644 index 000000000..4ec0026a6 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/request/ReadActivityCalendarRequest.java @@ -0,0 +1,18 @@ +package spring.backend.activity.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record ReadActivityCalendarRequest( + + @Min(value = 2024, message = "년도는 2024년 이상이어야 합니다.") + @Schema(description = "캘린더 조회 년도", example = "2024") + int year, + + @Min(value = 1, message = "월은 1~12 사이여야 합니다.") + @Max(value = 12, message = "월은 1~12 사이여야 합니다.") + @Schema(description = "캘린더 조회 월", example = "11") + int month +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/request/UserActivitySelectRequest.java b/src/main/java/spring/backend/activity/presentation/dto/request/UserActivitySelectRequest.java new file mode 100644 index 000000000..3e4829c54 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/request/UserActivitySelectRequest.java @@ -0,0 +1,36 @@ +package spring.backend.activity.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; + +public record UserActivitySelectRequest( + @NotNull(message = "활동 유형은 필수 입력 항목입니다.") + @Schema(description = "활동 유형 (ONLINE, OFFLINE, ONLINE_AND_OFFLINE)", example = "ONLINE") + Type type, + + @NotNull(message = "자투리 시간은 필수 입력 항목입니다.") + @Min(value = 10, message = "자투리 시간은 최소 10이어야 합니다.") + @Max(value = 300, message = "자투리 시간은 최대 300이어야 합니다.") + @Schema(description = "자투리 시간", example = "300") + Integer spareTime, + + @NotNull(message = "키워드는 필수 입력 항목입니다.") + @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}") + Keyword keyword, + + @NotNull(message = "타이틀은 필수 입력 항목입니다.") + @Schema(description = "타이틀", example = "카페에서 커피 마시며 책 읽기") + String title, + + @NotNull(message = "내용은 필수 입력 항목입니다.") + @Schema(description = "내용", example = "조용한 카페에서 좋아하는 책을 읽으며 여유로운 시간을 즐길 수 있습니다.") + String content, + + @Schema(description = "장소(활동 유형이 OFFLINE, ONLINE_AND_OFFLINE 인 경우에만 입력합니다.)", example = "서울시 강남구 역삼동") + String location +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/ActivitiesByMemberAndKeywordInMonthResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/ActivitiesByMemberAndKeywordInMonthResponse.java new file mode 100644 index 000000000..f2ea209f9 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/ActivitiesByMemberAndKeywordInMonthResponse.java @@ -0,0 +1,22 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.dto.response.ActivityWithTitleAndSavedTimeResponse; + +import java.util.List; + +public record ActivitiesByMemberAndKeywordInMonthResponse( + @Schema(description = "이번 달 해당 키워드 활동을 통해 모은 자투리 시간(분단위)", example = "120") + long totalSavedTimeByKeywordInMonth, + + @Schema(description = "활동 키워드별 활동 총 개수") + long totalActivityCountByKeywordInMonth, + + @Schema(description = "활동 키워드별 활동 목록") + List activities, + + @Schema(description = "키워드", example = "{\"category\":\"RELAXATION\",\"image\":\"https://d1zjcuqflbd5k.cloudfront.net/files/acc_1160/0/1160-2019-07-02-14-07-52-0000.jpg\"}") + Keyword keyword +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/ActivityCalendarResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/ActivityCalendarResponse.java new file mode 100644 index 000000000..83b98c6cc --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/ActivityCalendarResponse.java @@ -0,0 +1,20 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record ActivityCalendarResponse( + + @Schema(description = "사용자의 월별 활동 요약", + example = "{ \"registrationDate\": \"2024-11-14T06:10:55.091954\", \"totalSavedTime\": 120, \"monthlyActivityCount\": 10 }") + UserMonthlyActivitySummary summary, + + @Schema(description = "사용자의 월별 활동 상세 정보 리스트", + example = "{ \"category\": \"SELF_DEVELOPMENT\", \"title\": \"마음의 편안을 가져다주는 명상음악 20분 듣기\", \"savedTime\": 20, \"activityCreatedAt\": \"2024-11-16T14:24:08.548712\" }") + List monthlyActivities +) { + public static ActivityCalendarResponse of(UserMonthlyActivitySummary summary, List details) { + return new ActivityCalendarResponse(summary, details); + } +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/FinishActivityResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/FinishActivityResponse.java new file mode 100644 index 000000000..d3a23a962 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/FinishActivityResponse.java @@ -0,0 +1,15 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.dto.response.ActivityInfo; +import spring.backend.member.dto.response.HomeMemberInfoResponse; + +public record FinishActivityResponse( + + @Schema(description = "회원 정보") + HomeMemberInfoResponse member, + + @Schema(description = "활동 정보") + ActivityInfo activity +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/MonthlyActivityOverviewResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/MonthlyActivityOverviewResponse.java new file mode 100644 index 000000000..e5bdd2974 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/MonthlyActivityOverviewResponse.java @@ -0,0 +1,23 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.dto.response.MonthlyActivityCountByKeywordResponse; +import spring.backend.activity.dto.response.MonthlySavedTimeAndActivityCountResponse; + +import java.time.Month; +import java.util.List; + +public record MonthlyActivityOverviewResponse( + @Schema(description = "유저의 가입년도", example = "2024") + int joinedYear, + + @Schema(description = "유저의 가입월", example = "JANUARY") + Month joinedMonth, + + @Schema(description = "이번 달 총 모은 자투리 시간(분단위)과 활동횟수") + MonthlySavedTimeAndActivityCountResponse monthlySavedTimeAndActivityCount, + + @Schema(description = "Keyword 별 자투리 시간 및 활동 횟수 요약") + List activitiesByKeywordSummary +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/QuickStartActivitySelectResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/QuickStartActivitySelectResponse.java new file mode 100644 index 000000000..41c38d36e --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/QuickStartActivitySelectResponse.java @@ -0,0 +1,16 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.domain.value.Keyword; + +public record QuickStartActivitySelectResponse( + @Schema(description = "활동 ID", example = "1") + Long id, + + @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기") + String title, + + @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}") + Keyword keyword +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/UserActivitySelectResponse.java b/src/main/java/spring/backend/activity/presentation/dto/response/UserActivitySelectResponse.java new file mode 100644 index 000000000..e1d09e8cf --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/UserActivitySelectResponse.java @@ -0,0 +1,16 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.domain.value.Keyword; + +public record UserActivitySelectResponse( + @Schema(description = "활동 ID", example = "1") + Long id, + + @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기") + String title, + + @Schema(description = "활동 키워드", example = "{\"category\": \"SELF_DEVELOPMENT\", \"image\": \"https://example.com/image.jpg\"}") + Keyword keyword +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivityDetail.java b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivityDetail.java new file mode 100644 index 000000000..60df3d07e --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivityDetail.java @@ -0,0 +1,23 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.domain.value.Keyword.Category; + +import java.time.LocalDateTime; + +public record UserMonthlyActivityDetail( + + @Schema(description = "활동 카테고리 \n\n SELF_DEVELOPMENT(자기개발), HEALTH(건강), CULTURE_ART(문화/예술), ENTERTAINMENT(엔터테인먼트), RELAXATION(휴식), SOCIAL(소셜)", + example = "SELF_DEVELOPMENT") + Category category, + + @Schema(description = "활동 제목", example = "마음의 편안을 가져다주는 명상음악 20분 듣기") + String title, + + @Schema(description = "모은 시간", example = "20") + int savedTime, + + @Schema(description = "활동 생성 시간", example = "2024-11-16T14:24:08.548712") + LocalDateTime activityCreatedAt +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivitySummary.java b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivitySummary.java new file mode 100644 index 000000000..c3e5d08d7 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/dto/response/UserMonthlyActivitySummary.java @@ -0,0 +1,18 @@ +package spring.backend.activity.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +public record UserMonthlyActivitySummary( + + @Schema(description = "사용자 최초 가입 시간", example = "2024-11-14T06:10:55.091954") + LocalDateTime registrationDate, + + @Schema(description = "해당 달에 모은 시간 조각의 합 (분 단위)", example = "120") + long totalSavedTime, + + @Schema(description = "사용자가 해당 달에 한 활동의 총 횟수", example = "10") + long monthlyActivityCount +) { +} diff --git a/src/main/java/spring/backend/activity/presentation/swagger/FinishActivitySwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/FinishActivitySwagger.java new file mode 100644 index 000000000..e2ec09c61 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/swagger/FinishActivitySwagger.java @@ -0,0 +1,24 @@ +package spring.backend.activity.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.activity.presentation.dto.response.FinishActivityResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "Activity", description = "활동") +public interface FinishActivitySwagger { + + @Operation( + summary = "활동 종료 API", + description = "진행 중인 활동을 종료합니다. \n\n 이 API는 활동이 자투리 시간 내에서만 종료될 수 있습니다.", + operationId = "/v1/activities/{activityId}/finish" + ) + @ApiErrorCode({GlobalErrorCode.class, ActivityErrorCode.class}) + ResponseEntity> finishActivity(@Parameter(hidden = true) Member member, Long activityId); +} diff --git a/src/main/java/spring/backend/activity/presentation/swagger/QuickStartActivitySelectSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/QuickStartActivitySelectSwagger.java new file mode 100644 index 000000000..4db151932 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/swagger/QuickStartActivitySelectSwagger.java @@ -0,0 +1,28 @@ +package spring.backend.activity.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.activity.presentation.dto.request.QuickStartActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.QuickStartActivitySelectResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "Activity", description = "활동") +public interface QuickStartActivitySelectSwagger { + + @Operation( + summary = "빠른 시작 활동 선택 API", + description = "빠른 시작 활동을 선택합니다.", + operationId = "/v1/quick-starts/{quickStartId}/activities" + ) + @ApiErrorCode({ + GlobalErrorCode.class, ActivityErrorCode.class, QuickStartErrorCode.class + }) + ResponseEntity> quickStartUserActivitySelect(@Parameter(hidden = true) Member member, Long quickStartId, QuickStartActivitySelectRequest quickStartActivitySelectRequest); +} diff --git a/src/main/java/spring/backend/activity/presentation/swagger/ReadActivitiesByMemberAndKeywordInMonthSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivitiesByMemberAndKeywordInMonthSwagger.java new file mode 100644 index 000000000..18beb784e --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivitiesByMemberAndKeywordInMonthSwagger.java @@ -0,0 +1,22 @@ +package spring.backend.activity.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.activity.presentation.dto.request.ActivitiesByMemberAndKeywordInMonthRequest; +import spring.backend.activity.presentation.dto.response.ActivitiesByMemberAndKeywordInMonthResponse; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "Activity", description = "활동") +public interface ReadActivitiesByMemberAndKeywordInMonthSwagger { + @Operation( + summary = "특정 달 선택한 키워드 활동 조회 API", + description = "특정 달 선택한 키워드 활동을 조회합니다.", + operationId = "/v1/activities" + ) + ResponseEntity> readActivitiesByMemberAndKeywordInMonth( + @Parameter(hidden = true) Member member, ActivitiesByMemberAndKeywordInMonthRequest activitiesByMemberAndKeywordInMonthRequest + ); +} diff --git a/src/main/java/spring/backend/activity/presentation/swagger/ReadActivityCalendarSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivityCalendarSwagger.java new file mode 100644 index 000000000..476d4b387 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/swagger/ReadActivityCalendarSwagger.java @@ -0,0 +1,26 @@ +package spring.backend.activity.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import spring.backend.activity.presentation.dto.request.ReadActivityCalendarRequest; +import spring.backend.activity.presentation.dto.response.ActivityCalendarResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "Activity", description = "활동") +public interface ReadActivityCalendarSwagger { + + @Operation( + summary = "활동 캘린더 조회 API", + description = "사용자가 연월을 선택하여 월별 활동 요약과 월별 활동 상세 정보 리스트를 반환합니다.", + operationId = "/v1/activity-calendar" + ) + @ApiErrorCode({GlobalErrorCode.class, ActivityErrorCode.class}) + ResponseEntity> readActivityCalendar(@Parameter(hidden = true) Member member, @ParameterObject ReadActivityCalendarRequest request); +} diff --git a/src/main/java/spring/backend/activity/presentation/swagger/ReadMonthlyActivityOverviewSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/ReadMonthlyActivityOverviewSwagger.java new file mode 100644 index 000000000..633937659 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/swagger/ReadMonthlyActivityOverviewSwagger.java @@ -0,0 +1,20 @@ +package spring.backend.activity.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.activity.presentation.dto.request.MonthlyActivityOverviewRequest; +import spring.backend.activity.presentation.dto.response.MonthlyActivityOverviewResponse; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "Activity", description = "활동") +public interface ReadMonthlyActivityOverviewSwagger { + @Operation( + summary = "월간 활동 개요 조회 API", + description = "사용자의 월간 활동 개요를 조회합니다.", + operationId = "/v1/activities/overview" + ) + ResponseEntity> readMonthlyActivityOverview(@Parameter(hidden = true) Member member, MonthlyActivityOverviewRequest monthlyActivityOverviewRequest); +} diff --git a/src/main/java/spring/backend/activity/presentation/swagger/UserActivitySelectSwagger.java b/src/main/java/spring/backend/activity/presentation/swagger/UserActivitySelectSwagger.java new file mode 100644 index 000000000..fe9455b84 --- /dev/null +++ b/src/main/java/spring/backend/activity/presentation/swagger/UserActivitySelectSwagger.java @@ -0,0 +1,25 @@ +package spring.backend.activity.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.activity.presentation.dto.request.UserActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.UserActivitySelectResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "Activity", description = "활동") +public interface UserActivitySelectSwagger { + + @Operation( + summary = "사용자 활동 선택 API", + description = "사용자가 추천받은 활동 중 한가지 활동을 선택합니다.", + operationId = "/v1/activities" + ) + @ApiErrorCode({GlobalErrorCode.class, ActivityErrorCode.class}) + ResponseEntity> userActivitySelect(@Parameter(hidden = true) Member member, UserActivitySelectRequest userActivitySelectRequest); +} diff --git a/src/main/java/spring/backend/activity/query/dao/ActivityDao.java b/src/main/java/spring/backend/activity/query/dao/ActivityDao.java new file mode 100644 index 000000000..8eb2c52d8 --- /dev/null +++ b/src/main/java/spring/backend/activity/query/dao/ActivityDao.java @@ -0,0 +1,28 @@ +package spring.backend.activity.query.dao; + +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.dto.response.*; +import spring.backend.activity.dto.response.HomeActivityInfoResponse; +import spring.backend.activity.presentation.dto.response.UserMonthlyActivityDetail; +import spring.backend.activity.presentation.dto.response.UserMonthlyActivitySummary; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public interface ActivityDao { + + List findTodayActivities(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + UserMonthlyActivitySummary findActivitySummaryByYearAndMonth(UUID memberId, int year, int month); + + List findActivityDetailsByYearAndMonth(UUID memberId, int year, int month); + + MonthlySavedTimeAndActivityCountResponse findMonthlyTotalSavedTimeAndTotalCount(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + List findMonthlyActivitiesByKeywordSummary(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + List findActivitiesByMemberAndKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory); + + TotalSavedTimeAndActivityCountByKeywordInMonth findTotalSavedTimeAndActivityCountByKeywordInMonth(UUID memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Keyword.Category keywordCategory); +} diff --git a/src/main/java/spring/backend/auth/application/AuthorizeOAuthService.java b/src/main/java/spring/backend/auth/application/AuthorizeOAuthService.java new file mode 100644 index 000000000..289a96f2d --- /dev/null +++ b/src/main/java/spring/backend/auth/application/AuthorizeOAuthService.java @@ -0,0 +1,29 @@ +package spring.backend.auth.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.OAuthRestClient; +import spring.backend.auth.infrastructure.OAuthRestClientFactory; +import spring.backend.member.domain.value.Provider; + +import java.net.URI; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class AuthorizeOAuthService { + + private final OAuthRestClientFactory oAuthRestClientFactory; + + public URI getAuthorizeUrl(String providerName) { + if (providerName == null || providerName.isEmpty()) { + log.error("[AuthorizeOAuthService] Invalid provider name"); + throw AuthenticationErrorCode.NOT_EXIST_PROVIDER.toException(); + } + Provider provider = Provider.valueOf(providerName.toUpperCase()); + OAuthRestClient oAuthRestClient = oAuthRestClientFactory.getOAuthRestClient(provider); + return oAuthRestClient.getAuthUrl(); + } +} diff --git a/src/main/java/spring/backend/auth/application/HandleOAuthLoginService.java b/src/main/java/spring/backend/auth/application/HandleOAuthLoginService.java new file mode 100644 index 000000000..917127b64 --- /dev/null +++ b/src/main/java/spring/backend/auth/application/HandleOAuthLoginService.java @@ -0,0 +1,63 @@ +package spring.backend.auth.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.OAuthRestClient; +import spring.backend.auth.infrastructure.OAuthRestClientFactory; +import spring.backend.auth.presentation.dto.response.LoginResponse; +import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse; +import spring.backend.auth.presentation.dto.response.OAuthResourceResponse; +import spring.backend.core.application.JwtService; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.service.CreateMemberWithOAuthService; +import spring.backend.member.domain.value.Provider; +import spring.backend.member.presentation.dto.request.CreateMemberWithOAuthRequest; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class HandleOAuthLoginService { + + private final OAuthRestClientFactory oAuthRestClientFactory; + + private final CreateMemberWithOAuthService createMemberWithOAuthService; + + private final JwtService jwtService; + + private final RefreshTokenService refreshTokenService; + + public LoginResponse handleOAuthLogin(String providerName, String code, String state, String ip) { + if (providerName == null || providerName.isEmpty()) { + throw AuthenticationErrorCode.NOT_EXIST_PROVIDER.toException(); + } + Provider provider = Provider.valueOf(providerName.toUpperCase()); + OAuthRestClient oAuthRestClient = oAuthRestClientFactory.getOAuthRestClient(provider); + + OAuthAccessTokenResponse oAuthAccessTokenResponse = oAuthRestClient.getAccessToken(code, state); + + if (oAuthAccessTokenResponse == null) { + log.error("[HandleOAuthLoginService] OAuth access token could not be retrieved."); + throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException(); + } + + OAuthResourceResponse oAuthResourceResponse = oAuthRestClient.getResource(oAuthAccessTokenResponse.getAccessToken()); + + if (oAuthResourceResponse == null) { + log.error("[HandleOAuthLoginService] OAuth resource could not be retrieved."); + throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException(); + } + + CreateMemberWithOAuthRequest createMemberWithOAuthRequest = CreateMemberWithOAuthRequest.builder() + .provider(provider) + .email(oAuthResourceResponse.getEmail()) + .build(); + + Member member = createMemberWithOAuthService.createMemberWithOAuth(createMemberWithOAuthRequest); + String accessToken = jwtService.provideAccessToken(member); + String refreshToken = jwtService.provideRefreshToken(member, ip); + refreshTokenService.saveRefreshToken(refreshToken, member); + return LoginResponse.of(accessToken, refreshToken, member); + } +} diff --git a/src/main/java/spring/backend/auth/application/OnboardingSignUpService.java b/src/main/java/spring/backend/auth/application/OnboardingSignUpService.java new file mode 100644 index 000000000..08849d5df --- /dev/null +++ b/src/main/java/spring/backend/auth/application/OnboardingSignUpService.java @@ -0,0 +1,56 @@ +package spring.backend.auth.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.auth.presentation.dto.request.OnboardingSignUpRequest; +import spring.backend.auth.presentation.dto.response.OnboardingSignUpResponse; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; + +import java.time.Year; + +@Service +@RequiredArgsConstructor +@Transactional +@Log4j2 +public class OnboardingSignUpService { + + private static final int BIRTH_YEAR_RANGE = 100; + + private final MemberRepository memberRepository; + + public OnboardingSignUpResponse onboardingSignUp(Member member, OnboardingSignUpRequest request) { + validateMember(member); + validateRequest(request); + validateBirthYear(request); + member.convertGuestToMember(request.nickname(), request.birthYear(), request.gender(), request.profileImage()); + memberRepository.save(member); + return OnboardingSignUpResponse.of(member.getEmail(), member.getNickname(), member.getBirthYear(), member.getGender(), member.getProfileImage(), member.getCreatedAt()); + } + + private void validateMember(Member member) { + if (member == null || member.isMember()) { + log.error("[OnboardingSignUpService] Invalid member condition for sign-up."); + throw AuthenticationErrorCode.INVALID_MEMBER_SIGN_UP_CONDITION.toException(); + } + } + + private void validateRequest(OnboardingSignUpRequest request) { + if (request == null) { + log.error("[OnboardingSignUpService] Invalid request."); + throw AuthenticationErrorCode.NOT_EXIST_SIGN_UP_CONDITION.toException(); + } + } + + private void validateBirthYear(OnboardingSignUpRequest request) { + int birthYear = request.birthYear(); + int currentYear = Year.now().getValue(); + if (birthYear < (currentYear - BIRTH_YEAR_RANGE) || birthYear > currentYear) { + log.error("[OnboardingSignUpService] Invalid request birth year."); + throw AuthenticationErrorCode.INVALID_BIRTH_YEAR.toException(); + } + } +} diff --git a/src/main/java/spring/backend/auth/application/RefreshTokenService.java b/src/main/java/spring/backend/auth/application/RefreshTokenService.java new file mode 100644 index 000000000..b80d3fe1f --- /dev/null +++ b/src/main/java/spring/backend/auth/application/RefreshTokenService.java @@ -0,0 +1,67 @@ +package spring.backend.auth.application; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import spring.backend.auth.domain.repository.RefreshTokenRepository; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.core.application.JwtService; +import spring.backend.member.domain.entity.Member; + +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class RefreshTokenService { + private final JwtService jwtService; + private final long REFRESH_TOKEN_EXPIRATION; + private final RefreshTokenRepository refreshTokenRepository; + + public RefreshTokenService(JwtService jwtService, @Value("${jwt.refresh-token-expiry}") long refreshTokenExpiry, RefreshTokenRepository refreshTokenRepository) { + this.jwtService = jwtService; + this.REFRESH_TOKEN_EXPIRATION = refreshTokenExpiry; + this.refreshTokenRepository = refreshTokenRepository; + } + + public void saveRefreshToken(String refreshToken, Member member) { + refreshTokenRepository.save(refreshToken, member.getId(), REFRESH_TOKEN_EXPIRATION, convertChronoUnitToTimeUnit(ChronoUnit.DAYS)); + } + + public void validateRefreshToken(String refreshToken) { + String savedRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken); + if (savedRefreshToken == null || savedRefreshToken.isEmpty()) { + throw AuthenticationErrorCode.NOT_EXIST_REFRESH_TOKEN.toException(); + } + jwtService.getPayload(refreshToken); + } + + public void deleteRefreshToken(String refreshToken) { + if (refreshTokenRepository.findByRefreshToken(refreshToken) == null) { + log.error("리프레시 토큰이 저장소에 존재하지 않습니다."); + throw AuthenticationErrorCode.NOT_EXIST_REFRESH_TOKEN.toException(); + } + refreshTokenRepository.deleteByRefreshToken(refreshToken); + } + + private TimeUnit convertChronoUnitToTimeUnit(ChronoUnit chronoUnit) { + switch (chronoUnit) { + case NANOS: + return TimeUnit.NANOSECONDS; + case MICROS: + return TimeUnit.MICROSECONDS; + case MILLIS: + return TimeUnit.MILLISECONDS; + case SECONDS: + return TimeUnit.SECONDS; + case MINUTES: + return TimeUnit.MINUTES; + case HOURS: + return TimeUnit.HOURS; + case DAYS: + return TimeUnit.DAYS; + default: + throw AuthenticationErrorCode.UNSUPPORTED_REDIS_TIME_TYPE.toException(); + } + } +} diff --git a/src/main/java/spring/backend/auth/application/RotateAccessTokenService.java b/src/main/java/spring/backend/auth/application/RotateAccessTokenService.java new file mode 100644 index 000000000..d2905304b --- /dev/null +++ b/src/main/java/spring/backend/auth/application/RotateAccessTokenService.java @@ -0,0 +1,52 @@ +package spring.backend.auth.application; + +import com.maxmind.geoip2.exception.GeoIp2Exception; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.redis.repository.RefreshTokenRedisRepository; +import spring.backend.auth.presentation.dto.response.RotateTokenResponse; +import spring.backend.core.application.GeoLocationService; +import spring.backend.core.application.JwtService; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; + +import java.io.IOException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class RotateAccessTokenService { + private final MemberRepository memberRepository; + private final JwtService jwtService; + private final RefreshTokenService refreshTokenService; + private final RefreshTokenRedisRepository refreshTokenRedisRepository; + private final GeoLocationService geoLocationService; + + public RotateTokenResponse rotateToken(String refreshToken, String newIp) throws IOException, GeoIp2Exception { + if (refreshToken == null) { + throw AuthenticationErrorCode.MISSING_COOKIE_VALUE.toException(); + } + refreshTokenService.validateRefreshToken(refreshToken); + + String savedIp = jwtService.getPayload(refreshToken).get("ip", String.class); + + if (geoLocationService.checkUserLocation(newIp, savedIp)) { + refreshTokenService.deleteRefreshToken(refreshToken); + log.error("유효하지 않은 위치에서 토큰 재발급을 시도했습니다."); + throw AuthenticationErrorCode.TOKEN_ROTATE_ATTEMPT_FROM_INVALID_LOCATION.toException(); + } + + UUID memberId = UUID.fromString(refreshTokenRedisRepository.findByRefreshToken(refreshToken)); + Member member = memberRepository.findById(memberId); + String newAccessToken = jwtService.provideAccessToken(member); + String newRefreshToken = jwtService.provideRefreshToken(member, newIp); + refreshTokenService.saveRefreshToken(newRefreshToken, member); + return new RotateTokenResponse( + newAccessToken, + newRefreshToken + ); + } +} diff --git a/src/main/java/spring/backend/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/spring/backend/auth/domain/repository/RefreshTokenRepository.java new file mode 100644 index 000000000..b34abf16b --- /dev/null +++ b/src/main/java/spring/backend/auth/domain/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package spring.backend.auth.domain.repository; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public interface RefreshTokenRepository { + void save(String refreshToken, UUID memberId, Long expireTime, TimeUnit timeUnit); + String findByRefreshToken(String refreshToken); + void deleteByRefreshToken(String refreshToken); + void deleteAll(); +} diff --git a/src/main/java/spring/backend/auth/exception/AuthenticationErrorCode.java b/src/main/java/spring/backend/auth/exception/AuthenticationErrorCode.java new file mode 100644 index 000000000..a422fac27 --- /dev/null +++ b/src/main/java/spring/backend/auth/exception/AuthenticationErrorCode.java @@ -0,0 +1,43 @@ +package spring.backend.auth.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum AuthenticationErrorCode implements BaseErrorCode { + + NOT_EXIST_HEADER(HttpStatus.UNAUTHORIZED, "Authorization Header가 존재하지 않습니다."), + NOT_EXIST_TOKEN_In_COOKIE(HttpStatus.UNAUTHORIZED, "쿠키에 Token이 존재하지 않습니다."), + NOT_MATCH_TOKEN_FORMAT(HttpStatus.UNAUTHORIZED, "토큰의 형식이 맞지 않습니다."), + INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "토큰의 서명이 올바르지 않습니다."), + NOT_DEFINE_TOKEN(HttpStatus.UNAUTHORIZED, "정의되지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "유효한 OAuth 써드파티 제공자가 아닙니다."), + NOT_EXIST_PROVIDER(HttpStatus.BAD_REQUEST, "OAuth 써드파티 제공자가 존재하지 않습니다."), + NOT_EXIST_AUTH_CODE(HttpStatus.BAD_GATEWAY, "OAuth 써드파티 제공자에서 제공받은 인증 코드가 존재하지 않습니다."), + ACCESS_TOKEN_NOT_ISSUED(HttpStatus.BAD_GATEWAY, "OAuth 써드파티 제공자에서 액세스 토큰이 발급되지 않았습니다."), + NOT_EXIST_RESOURCE_RESPONSE(HttpStatus.BAD_GATEWAY, "OAuth 써드파티 리소스 서버에서 자원이 존재하지 않습니다."), + RESOURCE_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "OAuth Resource Server에 접근할 수 없습니다."), + UNSUPPORTED_REDIS_TIME_TYPE(HttpStatus.BAD_REQUEST, "Redis 만료시간은 ChronoUnit 타입이어야 합니다."), + MISMATCH_TOKEN_MEMBER(HttpStatus.UNAUTHORIZED, "토큰의 회원 ID와 요청한 회원 ID가 일치하지 않습니다."), + NOT_EXIST_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 저장소에 존재하지 않습니다."), + MISSING_COOKIE_VALUE(HttpStatus.BAD_REQUEST, "쿠키값이 존재하지 않습니다."), + INVALID_MEMBER_SIGN_UP_CONDITION(HttpStatus.BAD_REQUEST, "회원가입을 위한 사용자 조건이 유효하지 않습니다."), + NOT_EXIST_SIGN_UP_CONDITION(HttpStatus.BAD_REQUEST, "회원가입 요청이 유효하지 않습니다."), + INVALID_BIRTH_YEAR(HttpStatus.BAD_REQUEST, "출생년도는 현재 연도와 100년 전 사이여야 합니다."), + TOKEN_ROTATE_ATTEMPT_FROM_INVALID_LOCATION(HttpStatus.UNAUTHORIZED, "유효하지 않은 위치에서 토큰 재발급을 시도했습니다."), + FAILED_TO_EXTRACT_MEMBER_ID_FROM_EXPIRED_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "만료된 액세스 토큰에서 회원 ID를 추출하는데 실패했습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/auth/infrastructure/OAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClient.java new file mode 100644 index 000000000..113f4ff31 --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClient.java @@ -0,0 +1,15 @@ +package spring.backend.auth.infrastructure; + +import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse; +import spring.backend.auth.presentation.dto.response.OAuthResourceResponse; + +import java.net.URI; + +public interface OAuthRestClient { + + URI getAuthUrl(); + + OAuthAccessTokenResponse getAccessToken(String authCode, String state); + + OAuthResourceResponse getResource(String oauthToken); +} diff --git a/src/main/java/spring/backend/auth/infrastructure/OAuthRestClientFactory.java b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClientFactory.java new file mode 100644 index 000000000..4ae00826b --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/OAuthRestClientFactory.java @@ -0,0 +1,35 @@ +package spring.backend.auth.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.google.GoogleOAuthRestClient; +import spring.backend.auth.infrastructure.kakao.KakaoOAuthRestClient; +import spring.backend.auth.infrastructure.naver.NaverOAuthRestClient; +import spring.backend.member.domain.value.Provider; + +@Component +@RequiredArgsConstructor +public class OAuthRestClientFactory { + + private final GoogleOAuthRestClient googleOAuthRestClient; + + private final NaverOAuthRestClient naverOAuthRestClient; + + private final KakaoOAuthRestClient kakaoOAuthRestClient; + + public OAuthRestClient getOAuthRestClient(Provider provider) { + switch (provider) { + case GOOGLE -> { + return googleOAuthRestClient; + } + case NAVER -> { + return naverOAuthRestClient; + } + case KAKAO -> { + return kakaoOAuthRestClient; + } + default -> throw AuthenticationErrorCode.INVALID_PROVIDER.toException(); + } + } +} diff --git a/src/main/java/spring/backend/auth/infrastructure/google/GoogleOAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/google/GoogleOAuthRestClient.java new file mode 100644 index 000000000..c8a395c44 --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/google/GoogleOAuthRestClient.java @@ -0,0 +1,89 @@ +package spring.backend.auth.infrastructure.google; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; +import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse; +import spring.backend.auth.presentation.dto.response.OAuthResourceResponse; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.OAuthRestClient; +import spring.backend.core.configuration.property.oauth.GoogleOAuthProperty; + +import java.net.URI; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class GoogleOAuthRestClient implements OAuthRestClient { + + private static final String GRANT_TYPE = "authorization_code"; + + private final GoogleOAuthProperty googleOAuthProperty; + + @Override + public URI getAuthUrl() { + return UriComponentsBuilder.fromUriString(googleOAuthProperty.getAuthUri()) + .queryParam("client_id", googleOAuthProperty.getClientId()) + .queryParam("redirect_uri", googleOAuthProperty.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", googleOAuthProperty.getScope())) + .build() + .toUri(); + } + + @Override + public OAuthAccessTokenResponse getAccessToken(String authCode, String state) { + if (authCode == null || authCode.isEmpty()) { + log.error("[GoogleOAuthRestClient] authCode is null"); + throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException(); + } + try { + return RestClient.create() + .post() + .uri(googleOAuthProperty.getTokenUri()) + .headers(header -> header.setContentType(MediaType.APPLICATION_FORM_URLENCODED)) + .body(createAccessTokenRequestBody(authCode)) + .retrieve() + .body(OAuthAccessTokenResponse.class); + } catch (Exception e) { + log.error("[GoogleOAuthRestClient] error", e); + throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException(); + } + } + + @Override + public OAuthResourceResponse getResource(String oauthToken) { + if (oauthToken == null || oauthToken.isEmpty()) { + log.error("[GoogleOAuthRestClient] oauthToken is null"); + throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException(); + } + try { + return RestClient.create() + .get() + .uri(googleOAuthProperty.getResourceUri()) + .headers(header -> { + header.setBearerAuth(oauthToken); + header.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + }) + .retrieve() + .body(OAuthResourceResponse.class); + } catch (Exception e) { + throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException(); + } + } + + private MultiValueMap createAccessTokenRequestBody(String authCode) { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.add("client_id", googleOAuthProperty.getClientId()); + parameters.add("client_secret", googleOAuthProperty.getClientSecret()); + parameters.add("code", authCode); + parameters.add("grant_type", GRANT_TYPE); + parameters.add("redirect_uri", googleOAuthProperty.getRedirectUri()); + return parameters; + } +} diff --git a/src/main/java/spring/backend/auth/infrastructure/kakao/KakaoOAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/kakao/KakaoOAuthRestClient.java new file mode 100644 index 000000000..55997800a --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/kakao/KakaoOAuthRestClient.java @@ -0,0 +1,112 @@ +package spring.backend.auth.infrastructure.kakao; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; +import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse; +import spring.backend.auth.presentation.dto.response.OAuthResourceResponse; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.OAuthRestClient; +import spring.backend.auth.infrastructure.kakao.dto.KakaoResourceResponse; +import spring.backend.core.configuration.property.oauth.KakaoOAuthProperty; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class KakaoOAuthRestClient implements OAuthRestClient { + + private static final String GRANT_TYPE = "authorization_code"; + + private final KakaoOAuthProperty kakaoOAuthProperty; + + @Override + public URI getAuthUrl() { + return UriComponentsBuilder.fromUriString(kakaoOAuthProperty.getAuthUri()) + .queryParam("client_id", kakaoOAuthProperty.getClientId()) + .queryParam("redirect_uri", kakaoOAuthProperty.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", kakaoOAuthProperty.getScope())) + .build() + .toUri(); + } + + @Override + public OAuthAccessTokenResponse getAccessToken(String authCode, String state) { + if (authCode == null || authCode.isEmpty()) { + log.error("[KakaoOAuthRestClient] authCode is null"); + throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException(); + } + try { + return RestClient.create() + .post() + .uri(kakaoOAuthProperty.getTokenUri()) + .headers(header -> { + header.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8)); + }) + .body(createAccessTokenRequestBody(authCode)) + .retrieve() + .body(OAuthAccessTokenResponse.class); + } catch (Exception e) { + log.error("[GoogleOAuthRestClient] error", e); + throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException(); + } + } + + @Override + public OAuthResourceResponse getResource(String oauthToken) { + if (oauthToken == null || oauthToken.isEmpty()) { + log.error("[KakaoOAuthRestClient] oauthToken is null"); + throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException(); + } + try { + KakaoResourceResponse kakaoResourceResponse = RestClient.create() + .get() + .uri(kakaoOAuthProperty.getResourceUri()) + .headers(header -> { + header.setBearerAuth(oauthToken); + header.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8)); + }) + .retrieve() + .body(KakaoResourceResponse.class); + Long id = Optional.ofNullable(kakaoResourceResponse) + .map(KakaoResourceResponse::getId) + .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException); + KakaoResourceResponse.Response kakaoAccount = Optional.ofNullable(kakaoResourceResponse.getKakaoAccount()) + .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException); + String email = Optional.ofNullable(kakaoAccount.getEmail()) + .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException); + String nickname = Optional.ofNullable(kakaoAccount.getProfile()) + .map(KakaoResourceResponse.Response.Profile::getNickname) + .orElseThrow(AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE::toException); + return OAuthResourceResponse.builder() + .id(String.valueOf(id)) + .name(nickname) + .email(email) + .build(); + } catch (Exception e) { + throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException(); + } + } + + private MultiValueMap createAccessTokenRequestBody(String authCode) { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.add("client_id", kakaoOAuthProperty.getClientId()); + parameters.add("client_secret", kakaoOAuthProperty.getClientSecret()); + parameters.add("code", authCode); + parameters.add("grant_type", GRANT_TYPE); + parameters.add("redirect_uri", kakaoOAuthProperty.getRedirectUri()); + return parameters; + } +} diff --git a/src/main/java/spring/backend/auth/infrastructure/kakao/dto/KakaoResourceResponse.java b/src/main/java/spring/backend/auth/infrastructure/kakao/dto/KakaoResourceResponse.java new file mode 100644 index 000000000..ff61703a8 --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/kakao/dto/KakaoResourceResponse.java @@ -0,0 +1,26 @@ +package spring.backend.auth.infrastructure.kakao.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class KakaoResourceResponse { + + private Long id; + + @JsonProperty("kakao_account") + private Response kakaoAccount; + + @Getter + public static class Response { + + private String email; + + private Profile profile; + + @Getter + public static class Profile { + private String nickname; + } + } +} diff --git a/src/main/java/spring/backend/auth/infrastructure/naver/NaverOAuthRestClient.java b/src/main/java/spring/backend/auth/infrastructure/naver/NaverOAuthRestClient.java new file mode 100644 index 000000000..c5a1cb535 --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/naver/NaverOAuthRestClient.java @@ -0,0 +1,110 @@ +package spring.backend.auth.infrastructure.naver; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; +import spring.backend.auth.presentation.dto.response.OAuthAccessTokenResponse; +import spring.backend.auth.presentation.dto.response.OAuthResourceResponse; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.OAuthRestClient; +import spring.backend.auth.infrastructure.naver.dto.NaverResourceResponse; +import spring.backend.core.configuration.property.oauth.NaverOAuthProperty; + +import java.math.BigInteger; +import java.net.URI; +import java.security.SecureRandom; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class NaverOAuthRestClient implements OAuthRestClient { + + private static final String GRANT_TYPE = "authorization_code"; + + private final NaverOAuthProperty naverOAuthProperty; + + @Override + public URI getAuthUrl() { + return UriComponentsBuilder.fromUriString(naverOAuthProperty.getAuthUri()) + .queryParam("client_id", naverOAuthProperty.getClientId()) + .queryParam("redirect_uri", naverOAuthProperty.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("state", generateState()) + .build() + .toUri(); + } + + @Override + public OAuthAccessTokenResponse getAccessToken(String authCode, String state) { + if (authCode == null || authCode.isEmpty() || state == null || state.isEmpty()) { + log.error("[NaverOAuthProperty] authCode is null"); + throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException(); + } + try { + return RestClient.create() + .post() + .uri(naverOAuthProperty.getTokenUri()) + .headers(header -> header.setContentType(MediaType.APPLICATION_FORM_URLENCODED)) + .body(createAccessTokenRequestBody(authCode, state)) + .retrieve() + .body(OAuthAccessTokenResponse.class); + } catch (Exception e) { + log.error("[NaverOAuthProperty] error", e); + throw AuthenticationErrorCode.ACCESS_TOKEN_NOT_ISSUED.toException(); + } + } + + @Override + public OAuthResourceResponse getResource(String oauthToken) { + if (oauthToken == null || oauthToken.isEmpty()) { + log.error("[NaverOAuthProperty] oauthToken is null"); + throw AuthenticationErrorCode.NOT_EXIST_AUTH_CODE.toException(); + } + try { + NaverResourceResponse naverResourceResponse = RestClient.create() + .get() + .uri(naverOAuthProperty.getResourceUri()) + .headers(header -> { + header.setBearerAuth(oauthToken); + header.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + }) + .retrieve() + .body(NaverResourceResponse.class); + + if (naverResourceResponse == null) { + throw AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE.toException(); + } + NaverResourceResponse.Response resourceResponse = naverResourceResponse.getResponse(); + if (resourceResponse == null || resourceResponse.getId() == null || resourceResponse.getName() == null || resourceResponse.getEmail() == null) { + throw AuthenticationErrorCode.NOT_EXIST_RESOURCE_RESPONSE.toException(); + } + return OAuthResourceResponse.builder() + .id(resourceResponse.getId()) + .name(resourceResponse.getName()) + .email(resourceResponse.getEmail()) + .build(); + } catch (Exception e) { + throw AuthenticationErrorCode.RESOURCE_SERVER_UNAVAILABLE.toException(); + } + } + + private String generateState() { + SecureRandom random = new SecureRandom(); + return new BigInteger(130, random).toString(32); + } + + private MultiValueMap createAccessTokenRequestBody(String authCode, String state) { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.add("client_id", naverOAuthProperty.getClientId()); + parameters.add("client_secret", naverOAuthProperty.getClientSecret()); + parameters.add("grant_type", GRANT_TYPE); + parameters.add("state", state); + parameters.add("code", authCode); + return parameters; + } +} diff --git a/src/main/java/spring/backend/auth/infrastructure/naver/dto/NaverResourceResponse.java b/src/main/java/spring/backend/auth/infrastructure/naver/dto/NaverResourceResponse.java new file mode 100644 index 000000000..2759b2027 --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/naver/dto/NaverResourceResponse.java @@ -0,0 +1,16 @@ +package spring.backend.auth.infrastructure.naver.dto; + +import lombok.Getter; + +@Getter +public class NaverResourceResponse { + + private Response response; + + @Getter + public static class Response { + private String id; + private String name; + private String email; + } +} diff --git a/src/main/java/spring/backend/auth/infrastructure/redis/repository/RefreshTokenRedisRepository.java b/src/main/java/spring/backend/auth/infrastructure/redis/repository/RefreshTokenRedisRepository.java new file mode 100644 index 000000000..0beed659c --- /dev/null +++ b/src/main/java/spring/backend/auth/infrastructure/redis/repository/RefreshTokenRedisRepository.java @@ -0,0 +1,71 @@ +package spring.backend.auth.infrastructure.redis.repository; + +import io.lettuce.core.RedisConnectionException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; +import spring.backend.auth.domain.repository.RefreshTokenRepository; +import spring.backend.core.exception.error.GlobalErrorCode; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +@Log4j2 +public class RefreshTokenRedisRepository implements RefreshTokenRepository { + private final RedisTemplate redisTemplate; + + @Override + public void save(String refreshToken, UUID memberId, Long expireTime, TimeUnit timeUnit) { + try { + ValueOperations valueOperations = redisTemplate.opsForValue(); + valueOperations.set(refreshToken, memberId.toString(), expireTime, timeUnit); + } catch (RedisConnectionException e) { + log.error("Redis 연결 오류 : {}", e.getMessage()); + throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException(); + } catch (Exception e) { + throw GlobalErrorCode.INTERNAL_ERROR.toException(); + } + } + + @Override + public String findByRefreshToken(String refreshToken) { + try { + ValueOperations valueOperations = redisTemplate.opsForValue(); + return valueOperations.get(refreshToken); + } catch (RedisConnectionException e) { + log.error("Redis 연결 오류 : {}", e.getMessage()); + throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException(); + } catch (Exception e) { + throw GlobalErrorCode.INTERNAL_ERROR.toException(); + } + } + + @Override + public void deleteByRefreshToken(String refreshToken) { + try { + redisTemplate.delete(refreshToken); + } catch (RedisConnectionException e) { + log.error("Redis 연결 오류 : {}", e.getMessage()); + throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException(); + } catch (Exception e) { + throw GlobalErrorCode.INTERNAL_ERROR.toException(); + } + } + + @Override + public void deleteAll() { + try { + redisTemplate.getConnectionFactory().getConnection().flushDb(); + } catch (RedisConnectionException e) { + log.error("Redis 연결 오류 : {}", e.getMessage()); + throw GlobalErrorCode.REDIS_CONNECTION_ERROR.toException(); + } catch (Exception e) { + throw GlobalErrorCode.INTERNAL_ERROR.toException(); + } + } + +} diff --git a/src/main/java/spring/backend/auth/presentation/AuthorizeOAuthController.java b/src/main/java/spring/backend/auth/presentation/AuthorizeOAuthController.java new file mode 100644 index 000000000..cde03957b --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/AuthorizeOAuthController.java @@ -0,0 +1,25 @@ +package spring.backend.auth.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.view.RedirectView; +import spring.backend.auth.application.AuthorizeOAuthService; + +import java.net.URI; + +@Controller +@RequestMapping("/v1/oauth") +@RequiredArgsConstructor +public class AuthorizeOAuthController { + + private final AuthorizeOAuthService authorizeOAuthService; + + @GetMapping("/{providerName}") + public RedirectView authorizeOAuth(@PathVariable String providerName) { + URI authUrl = authorizeOAuthService.getAuthorizeUrl(providerName); + return new RedirectView(authUrl.toASCIIString()); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/HandleOAuthLoginController.java b/src/main/java/spring/backend/auth/presentation/HandleOAuthLoginController.java new file mode 100644 index 000000000..0b9c3ffbd --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/HandleOAuthLoginController.java @@ -0,0 +1,47 @@ +package spring.backend.auth.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import spring.backend.auth.application.HandleOAuthLoginService; +import spring.backend.auth.presentation.dto.response.LoginResponse; +import spring.backend.auth.presentation.dto.response.LoginUserInfoResponse; +import spring.backend.core.configuration.argumentresolver.ClientIp; +import spring.backend.core.presentation.RestResponse; + +@RestController +@RequestMapping("/v1/oauth/login") +@RequiredArgsConstructor +public class HandleOAuthLoginController { + + private final HandleOAuthLoginService handleOAuthLoginService; + + @GetMapping("/{providerName}") + public ResponseEntity> handleOAuthLogin(@RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, @PathVariable String providerName, @ClientIp String ip) { + LoginResponse loginResponse = handleOAuthLoginService.handleOAuthLogin(providerName, code, state, ip); + ResponseCookie accessTokenCookie = ResponseCookie.from("access_token", loginResponse.accessToken()) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .build(); + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", loginResponse.refreshToken()) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .build(); + + LoginUserInfoResponse loginUserInfoResponse = LoginUserInfoResponse.from(loginResponse); + + return ResponseEntity.ok() + .headers(header -> { + header.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + header.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + }) + .body(new RestResponse<>(loginUserInfoResponse)); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/LogoutController.java b/src/main/java/spring/backend/auth/presentation/LogoutController.java new file mode 100644 index 000000000..b28711ae6 --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/LogoutController.java @@ -0,0 +1,40 @@ +package spring.backend.auth.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.auth.application.RefreshTokenService; +import spring.backend.auth.presentation.swagger.LogoutSwagger; +import spring.backend.core.presentation.RestResponse; + +@RestController +@RequiredArgsConstructor +public class LogoutController implements LogoutSwagger { + private final RefreshTokenService refreshTokenService; + + @PostMapping("/v1/logout") + public ResponseEntity> logout( + @CookieValue(name = "access_token", required = false) String accessToken, + @CookieValue(name = "refresh_token", required = false) String refreshToken + ) { + refreshTokenService.deleteRefreshToken(refreshToken); + ResponseCookie accessTokenCookie = ResponseCookie.from("access_token", "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build(); + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + .build(); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/OnboardingSignUpController.java b/src/main/java/spring/backend/auth/presentation/OnboardingSignUpController.java new file mode 100644 index 000000000..3dca0039e --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/OnboardingSignUpController.java @@ -0,0 +1,30 @@ +package spring.backend.auth.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.auth.application.OnboardingSignUpService; +import spring.backend.auth.presentation.dto.request.OnboardingSignUpRequest; +import spring.backend.auth.presentation.dto.response.OnboardingSignUpResponse; +import spring.backend.auth.presentation.swagger.OnboardingSignUpSwagger; +import spring.backend.core.configuration.argumentresolver.LoginMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class OnboardingSignUpController implements OnboardingSignUpSwagger { + + private final OnboardingSignUpService onboardingSignUpService; + + @Authorization + @PostMapping("/v1/members/onboard") + public ResponseEntity> onboardingSignUp(@LoginMember Member member, @Valid @RequestBody OnboardingSignUpRequest request) { + OnboardingSignUpResponse onboardingSignUpResponse = onboardingSignUpService.onboardingSignUp(member, request); + return ResponseEntity.ok(new RestResponse<>(onboardingSignUpResponse)); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/RotateAccessTokenController.java b/src/main/java/spring/backend/auth/presentation/RotateAccessTokenController.java new file mode 100644 index 000000000..28a7202a4 --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/RotateAccessTokenController.java @@ -0,0 +1,55 @@ +package spring.backend.auth.presentation; + +import com.maxmind.geoip2.exception.GeoIp2Exception; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.auth.application.RotateAccessTokenService; +import spring.backend.auth.presentation.dto.response.RotateTokenResponse; +import spring.backend.auth.presentation.swagger.RotateTokenSwagger; +import spring.backend.core.configuration.argumentresolver.ClientIp; +import spring.backend.core.presentation.RestResponse; + +import java.io.IOException; + +import static org.springframework.http.ResponseCookie.from; + +@RestController +@RequestMapping("/v1/token/rotate") +@RequiredArgsConstructor +@Log4j2 +public class RotateAccessTokenController implements RotateTokenSwagger { + private final RotateAccessTokenService rotateTokenService; + + @PostMapping + public ResponseEntity> rotateToken( + @CookieValue(name = "refresh_token", required = false) String refreshToken, + @ClientIp String ip + ) throws IOException, GeoIp2Exception { + RotateTokenResponse rotateTokenResponse = rotateTokenService.rotateToken(refreshToken, ip); + ResponseCookie newAccessToken = from("access_token", rotateTokenResponse.accessToken()) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .build(); + + ResponseCookie newRefreshToken = from("refresh_token", rotateTokenResponse.refreshToken()) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, newAccessToken.toString()) + .header(HttpHeaders.SET_COOKIE, newRefreshToken.toString()) + .build(); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/dto/request/OnboardingSignUpRequest.java b/src/main/java/spring/backend/auth/presentation/dto/request/OnboardingSignUpRequest.java new file mode 100644 index 000000000..d37e3936d --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/dto/request/OnboardingSignUpRequest.java @@ -0,0 +1,24 @@ +package spring.backend.auth.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import spring.backend.member.domain.value.Gender; + +public record OnboardingSignUpRequest( + + @Pattern(regexp = "^[a-zA-Z0-9가-힣ㄱ-ㅎ]{1,6}$", message = "닉네임은 한글, 영문, 숫자 조합 6자 이내로 입력해주세요.") + @Schema(description = "닉네임", example = "조각조각") + String nickname, + + @Schema(description = "출생년도", example = "2001") + int birthYear, + + @NotNull(message = "성별을 입력해주세요.") + @Schema(description = "성별 (MALE, FEMALE, NONE)", example = "FEMALE") + Gender gender, + + @NotBlank(message = "프로필 이미지를 선택해주세요.") + @Schema(description = "프로필 이미지", example = "http://test.jpg") + String profileImage +) { +} diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/LoginResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/LoginResponse.java new file mode 100644 index 000000000..f84c17f0e --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/dto/response/LoginResponse.java @@ -0,0 +1,52 @@ +package spring.backend.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.value.Gender; +import spring.backend.member.domain.value.Role; + +import java.time.LocalDateTime; + +public record LoginResponse( + + @Schema(description = "액세스 토큰") + String accessToken, + + @Schema(description = "리프레시 토큰") + String refreshToken, + + @Schema(description = "사용자 정보") + UserInfo userInfo +) { + public record UserInfo( + + @Schema(description = "사용자 유형(MEMBER, GUEST)", example = "MEMBER") + Role role, + + @Schema(description = "사용자 이메일", example = "example@example.com") + String email, + + @Schema(description = "사용자 닉네임", example = "john_doe") + String nickname, + + @Schema(description = "사용자 출생 연도", example = "1990") + int birthYear, + + @Schema(description = "사용자 성별 (MALE, FEMALE, NONE)", example = "MALE") + Gender gender, + + @Schema(description = "사용자 프로필 이미지 URL", example = "https://example.com/profile.jpg") + String profileImage, + + @Schema(description = "사용자 가입 날짜", example = "2024-11-14T06:10:55.091954") + LocalDateTime registrationDate + ) { + public static UserInfo from(Member member) { + return new UserInfo(member.getRole(), member.getEmail(), member.getNickname(), member.getBirthYear(), member.getGender(), member.getProfileImage(), member.getCreatedAt()); + } + } + + public static LoginResponse of(String accessToken, String refreshToken, Member member) { + return new LoginResponse(accessToken, refreshToken, UserInfo.from(member)); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/LoginUserInfoResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/LoginUserInfoResponse.java new file mode 100644 index 000000000..82d0f3550 --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/dto/response/LoginUserInfoResponse.java @@ -0,0 +1,42 @@ +package spring.backend.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.member.domain.value.Gender; +import spring.backend.member.domain.value.Role; + +import java.time.LocalDateTime; + +public record LoginUserInfoResponse( + @Schema(description = "사용자 유형(MEMBER, GUEST)", example = "MEMBER") + Role role, + + @Schema(description = "사용자 이메일", example = "example@example.com") + String email, + + @Schema(description = "사용자 닉네임", example = "john_doe") + String nickname, + + @Schema(description = "사용자 출생 연도", example = "1990") + int birthYear, + + @Schema(description = "사용자 성별 (MALE, FEMALE, NONE)", example = "MALE") + Gender gender, + + @Schema(description = "사용자 프로필 이미지 URL", example = "https://example.com/profile.jpg") + String profileImage, + + @Schema(description = "사용자 가입 날짜", example = "2024-11-14T06:10:55.091954") + LocalDateTime registrationDate +) { + public static LoginUserInfoResponse from(LoginResponse loginResponse) { + return new LoginUserInfoResponse( + loginResponse.userInfo().role(), + loginResponse.userInfo().email(), + loginResponse.userInfo().nickname(), + loginResponse.userInfo().birthYear(), + loginResponse.userInfo().gender(), + loginResponse.userInfo().profileImage(), + loginResponse.userInfo().registrationDate() + ); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/OAuthAccessTokenResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthAccessTokenResponse.java new file mode 100644 index 000000000..b6327a6f3 --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthAccessTokenResponse.java @@ -0,0 +1,20 @@ +package spring.backend.auth.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class OAuthAccessTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private long expiresIn; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("token_type") + private String tokenType; +} diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/OAuthResourceResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthResourceResponse.java new file mode 100644 index 000000000..2da417378 --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/dto/response/OAuthResourceResponse.java @@ -0,0 +1,19 @@ +package spring.backend.auth.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OAuthResourceResponse { + + private String id; + + private String email; + + @JsonProperty("verified_email") + private boolean verifiedEmail; + + private String name; +} diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/OnboardingSignUpResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/OnboardingSignUpResponse.java new file mode 100644 index 000000000..490b9d43d --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/dto/response/OnboardingSignUpResponse.java @@ -0,0 +1,31 @@ +package spring.backend.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.member.domain.value.Gender; + +import java.time.LocalDateTime; + +public record OnboardingSignUpResponse( + + @Schema(description = "사용자 이메일", example = "example@example.com") + String email, + + @Schema(description = "사용자 닉네임", example = "john_doe") + String nickname, + + @Schema(description = "사용자 출생 연도", example = "1990") + int birthYear, + + @Schema(description = "사용자 성별 (MALE, FEMALE, NONE)", example = "MALE") + Gender gender, + + @Schema(description = "사용자 프로필 이미지 URL", example = "https://example.com/profile.jpg") + String profileImage, + + @Schema(description = "사용자 가입 날짜", example = "2024-11-14T06:10:55.091954") + LocalDateTime registrationDate +){ + public static OnboardingSignUpResponse of(String email, String nickname, int birthYear, Gender gender, String profileImage, LocalDateTime registrationDate) { + return new OnboardingSignUpResponse(email, nickname, birthYear, gender, profileImage, registrationDate); + } +} diff --git a/src/main/java/spring/backend/auth/presentation/dto/response/RotateTokenResponse.java b/src/main/java/spring/backend/auth/presentation/dto/response/RotateTokenResponse.java new file mode 100644 index 000000000..b1902eae0 --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/dto/response/RotateTokenResponse.java @@ -0,0 +1,4 @@ +package spring.backend.auth.presentation.dto.response; + +public record RotateTokenResponse(String accessToken, String refreshToken) { +} diff --git a/src/main/java/spring/backend/auth/presentation/swagger/LogoutSwagger.java b/src/main/java/spring/backend/auth/presentation/swagger/LogoutSwagger.java new file mode 100644 index 000000000..11c75ec5e --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/swagger/LogoutSwagger.java @@ -0,0 +1,29 @@ +package spring.backend.auth.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; + +@Tag(name = "Auth", description = "인증/인가") +public interface LogoutSwagger { + + @Operation( + summary = "로그아웃 API", + description = "사용자의 로그아웃을 진행합니다. \n\n 로그아웃 시, 사용자의 토큰이 무효화되어, 다시 로그인을 진행해야 합니다.", + operationId = "/v1/logout" + ) + @ApiErrorCode({ + GlobalErrorCode.class, AuthenticationErrorCode.class + }) + ResponseEntity> logout( + @Parameter(description = "쿠키에 있는 access_token", required = false) + String accessToken, + @Parameter(description = "쿠키에 있는 refresh_token", required = false) + String refreshToken + ); +} diff --git a/src/main/java/spring/backend/auth/presentation/swagger/OnboardingSignUpSwagger.java b/src/main/java/spring/backend/auth/presentation/swagger/OnboardingSignUpSwagger.java new file mode 100644 index 000000000..0e5138c62 --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/swagger/OnboardingSignUpSwagger.java @@ -0,0 +1,26 @@ +package spring.backend.auth.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.auth.presentation.dto.request.OnboardingSignUpRequest; +import spring.backend.auth.presentation.dto.response.OnboardingSignUpResponse; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.exception.MemberErrorCode; + +@Tag(name = "Auth", description = "인증/인가") +public interface OnboardingSignUpSwagger { + + @Operation( + summary = "가입 온보딩 API", + description = "사용자가 닉네임, 나이, 성별, 프로필사진을 입력하여 회원가입을 진행합니다", + operationId = "/v1/members/onboard" + ) + @ApiErrorCode({GlobalErrorCode.class, AuthenticationErrorCode.class, MemberErrorCode.class}) + ResponseEntity> onboardingSignUp(@Parameter(hidden = true) Member member, OnboardingSignUpRequest request); +} diff --git a/src/main/java/spring/backend/auth/presentation/swagger/RotateTokenSwagger.java b/src/main/java/spring/backend/auth/presentation/swagger/RotateTokenSwagger.java new file mode 100644 index 000000000..406be4c1e --- /dev/null +++ b/src/main/java/spring/backend/auth/presentation/swagger/RotateTokenSwagger.java @@ -0,0 +1,29 @@ +package spring.backend.auth.presentation.swagger; + +import com.maxmind.geoip2.exception.GeoIp2Exception; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.presentation.dto.response.RotateTokenResponse; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; + +import java.io.IOException; + +@Tag(name = "Auth", description = "인증/인가") +public interface RotateTokenSwagger { + @Operation( + summary = "토큰 재발급 API", + description = "Access Token이 만료된 경우, Refresh Token을 이용하여 새로운 Access Token을 발급합니다", + operationId = "/v1/token/rotate" + ) + @ApiErrorCode({GlobalErrorCode.class, AuthenticationErrorCode.class}) + ResponseEntity> rotateToken( + @Parameter(description = "쿠키에 있는 refresh_token", required = false) + String refreshToken, + @Parameter(hidden = true) String ip + ) throws IOException, GeoIp2Exception; +} diff --git a/src/main/java/spring/backend/core/application/GeoLocationService.java b/src/main/java/spring/backend/core/application/GeoLocationService.java new file mode 100644 index 000000000..72595637e --- /dev/null +++ b/src/main/java/spring/backend/core/application/GeoLocationService.java @@ -0,0 +1,51 @@ +package spring.backend.core.application; + +import com.maxmind.geoip2.DatabaseReader; +import com.maxmind.geoip2.exception.GeoIp2Exception; +import com.maxmind.geoip2.model.CityResponse; +import com.maxmind.geoip2.record.Location; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import spring.backend.core.util.geo.dto.response.Coordinate; + +import java.io.IOException; +import java.net.InetAddress; + +import static spring.backend.core.util.geo.GeoUtil.calculateDistanceBetweenTwoCoordinate; + +@Service +@Log4j2 +public class GeoLocationService { + private final DatabaseReader reader; + private final int MAX_DISTANCE; + + public GeoLocationService(@Value("${geo.max-distance}") int maxDistance, DatabaseReader reader) { + this.reader = reader; + this.MAX_DISTANCE = maxDistance; + } + + public boolean checkUserLocation(String newIp, String savedIp) throws IOException, GeoIp2Exception { + Coordinate newIpCoordinate = getCoordinate(newIp); + Coordinate savedIpCoordinate = getCoordinate(savedIp); + + double distanceBetweenIp = calculateDistanceBetweenTwoCoordinate( + savedIpCoordinate.latitude(), + savedIpCoordinate.longitude(), + newIpCoordinate.latitude(), + newIpCoordinate.longitude() + ); + + return distanceBetweenIp > MAX_DISTANCE; + } + + private Coordinate getCoordinate(String ip) throws GeoIp2Exception, IOException { + InetAddress ipAddress = InetAddress.getByName(ip); + CityResponse response = reader.city(ipAddress); + Location location = response.getLocation(); + return Coordinate.of( + location.getLatitude(), + location.getLongitude() + ); + } +} diff --git a/src/main/java/spring/backend/core/application/JwtService.java b/src/main/java/spring/backend/core/application/JwtService.java new file mode 100644 index 000000000..8f1886a49 --- /dev/null +++ b/src/main/java/spring/backend/core/application/JwtService.java @@ -0,0 +1,124 @@ +package spring.backend.core.application; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.member.domain.entity.Member; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + + +@Service +@Log4j2 +public class JwtService { + + @Getter + @RequiredArgsConstructor + public enum Type { + ACCESS("access"), REFRESH("refresh"); + private final String type; + } + + private final SecretKey SECRET_KEY; + private final long ACCESS_EXPIRATION; + private final long REFRESH_EXPIRATION; + + public JwtService(@Value("${jwt.secret}") String secret, @Value("${jwt.access-token-expiry}") long accessTokenExpiry, @Value("${jwt.refresh-token-expiry}") long refreshTokenExpiry) { + this.SECRET_KEY = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.ACCESS_EXPIRATION = accessTokenExpiry; + this.REFRESH_EXPIRATION = refreshTokenExpiry; + } + + public String provideAccessToken(Member member) { + return provideToken( + member.getEmail(), + member.getId(), + Type.ACCESS, + ACCESS_EXPIRATION, + "" + ); + } + + public String provideRefreshToken(Member member, String ip) { + return provideToken( + member.getEmail(), + member.getId(), + Type.REFRESH, + REFRESH_EXPIRATION, + ip + ); + } + + public Claims getPayload(String token) { + try { + return Jwts.parser() + .verifyWith(SECRET_KEY) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SignatureException e) { + log.error("Invalid signature", e); + throw AuthenticationErrorCode.INVALID_SIGNATURE.toException(); + } catch (ExpiredJwtException e) { + log.error("Expired token", e); + throw AuthenticationErrorCode.EXPIRED_TOKEN.toException(); + } catch (MalformedJwtException e) { + log.error("Invalid token format", e); + throw AuthenticationErrorCode.NOT_MATCH_TOKEN_FORMAT.toException(); + } catch (Exception e) { + log.error("Failed to parse token", e); + throw AuthenticationErrorCode.NOT_DEFINE_TOKEN.toException(); + } + } + + public UUID extractMemberId(String token) { + Claims claims = getPayload(token); + return UUID.fromString(claims.get("memberId", String.class)); + } + + public void validateTokenExpiration(String token) { + Claims claims = getPayload(token); + if (claims.getExpiration().before(new Date())) { + log.error("Token has expired, token: {}", token); + throw AuthenticationErrorCode.EXPIRED_TOKEN.toException(); + } + } + + private String provideToken(String email, UUID id, Type type, long expiration, String ip) { + Date expiryDate; + Map claims; + if (type == Type.ACCESS) { + expiryDate = Date.from(Instant.now().plus(expiration, ChronoUnit.SECONDS)); + claims = Map.of( + "memberId", id.toString(), + "email", email + ); + } else { + expiryDate = Date.from(Instant.now().plus(expiration, ChronoUnit.DAYS)); + claims = Map.of( + "ip", ip + ); + } + return Jwts.builder() + .claims(claims) + .issuedAt(new Date()) + .expiration(expiryDate) + .signWith(SECRET_KEY) + .compact(); + } +} diff --git a/src/main/java/spring/backend/core/configuration/ApplicationContextProvider.java b/src/main/java/spring/backend/core/configuration/ApplicationContextProvider.java new file mode 100644 index 000000000..d7de49c2a --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/ApplicationContextProvider.java @@ -0,0 +1,21 @@ +package spring.backend.core.configuration; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@Component +public class ApplicationContextProvider implements ApplicationContextAware { + + private static ApplicationContext applicationContext; + + public static T getBean(String name, Class requiredType) { + return applicationContext.getBean(name, requiredType); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + ApplicationContextProvider.applicationContext = applicationContext; + } +} diff --git a/src/main/java/spring/backend/core/configuration/AsyncConfiguration.java b/src/main/java/spring/backend/core/configuration/AsyncConfiguration.java new file mode 100644 index 000000000..aed540bb8 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/AsyncConfiguration.java @@ -0,0 +1,24 @@ +package spring.backend.core.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfiguration { + + @Bean + public Executor mailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); + executor.setMaxPoolSize(30); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("MailExecutor-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/spring/backend/core/configuration/GeoIpConfiguration.java b/src/main/java/spring/backend/core/configuration/GeoIpConfiguration.java new file mode 100644 index 000000000..cc68c664c --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/GeoIpConfiguration.java @@ -0,0 +1,21 @@ +package spring.backend.core.configuration; + +import com.maxmind.db.CHMCache; +import com.maxmind.geoip2.DatabaseReader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; + +@Configuration +public class GeoIpConfiguration { + + @Bean + public DatabaseReader databaseReader() throws IOException { + ClassPathResource resource = new ClassPathResource("maxmind/GeoLite2-City.mmdb"); + return new DatabaseReader.Builder(resource.getInputStream()) + .withCache(new CHMCache()) + .build(); + } +} diff --git a/src/main/java/spring/backend/core/configuration/RabbitMQConfiguration.java b/src/main/java/spring/backend/core/configuration/RabbitMQConfiguration.java new file mode 100644 index 000000000..9c6c32c2f --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/RabbitMQConfiguration.java @@ -0,0 +1,71 @@ +package spring.backend.core.configuration; + +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.CustomExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.boot.autoconfigure.amqp.RabbitProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import spring.backend.core.configuration.property.queue.FinishActivityQueueProperty; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class RabbitMQConfiguration { + + private final RabbitProperties rabbitProperties; + + private final FinishActivityQueueProperty finishActivityQueueProperty; + + @Bean + RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); + return rabbitTemplate; + } + + @Bean + public MessageConverter rabbitMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public ConnectionFactory rabbitConnectionFactory() { + CachingConnectionFactory connectionFactory = new CachingConnectionFactory(); + connectionFactory.setHost(rabbitProperties.getHost()); + connectionFactory.setPort(rabbitProperties.getPort()); + connectionFactory.setUsername(rabbitProperties.getUsername()); + connectionFactory.setPassword(rabbitProperties.getPassword()); + connectionFactory.setCacheMode(CachingConnectionFactory.CacheMode.CHANNEL); + return connectionFactory; + } + + @Bean + public CustomExchange finishActivityExchange() { + Map args = new HashMap<>(); + args.put("x-delayed-type", "direct"); + return new CustomExchange(finishActivityQueueProperty.getExchange(), "x-delayed-message", true, false, args); + } + + @Bean + Queue finishActivityQueue() { + return new Queue(finishActivityQueueProperty.getQueue(), false); + } + + @Bean + Binding bindingFinishActivityQueue(CustomExchange finishActivityExchange) { + return BindingBuilder.bind(finishActivityQueue()) + .to(finishActivityExchange) + .with(finishActivityQueueProperty.getRoutingKey()) + .noargs(); + } +} diff --git a/src/main/java/spring/backend/core/configuration/WebMvcConfiguration.java b/src/main/java/spring/backend/core/configuration/WebMvcConfiguration.java new file mode 100644 index 000000000..e8a06297d --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/WebMvcConfiguration.java @@ -0,0 +1,48 @@ +package spring.backend.core.configuration; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import spring.backend.core.configuration.argumentresolver.AuthorizedMemberArgumentResolver; +import spring.backend.core.configuration.argumentresolver.ClientIpArgumentResolver; +import spring.backend.core.configuration.argumentresolver.LoginMemberArgumentResolver; +import spring.backend.core.configuration.interceptor.AuthorizationInterceptor; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfiguration implements WebMvcConfigurer { + + private final AuthorizationInterceptor authorizationInterceptor; + + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + + private final AuthorizedMemberArgumentResolver authorizedMemberArgumentResolver; + + private final ClientIpArgumentResolver clientIpArgumentResolver; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000", "https://cnergy.kro.kr", "https://cnergy.p-e.kr", "http://localhost:5173") + .allowedMethods("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowCredentials(true) + .maxAge(3000); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authorizationInterceptor); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(clientIpArgumentResolver); + resolvers.add(loginMemberArgumentResolver); + resolvers.add(authorizedMemberArgumentResolver); + } +} diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMember.java b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMember.java new file mode 100644 index 000000000..2d3ea6494 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMember.java @@ -0,0 +1,11 @@ +package spring.backend.core.configuration.argumentresolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthorizedMember { +} diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolver.java b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolver.java new file mode 100644 index 000000000..1cbb7e397 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolver.java @@ -0,0 +1,32 @@ +package spring.backend.core.configuration.argumentresolver; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.exception.MemberErrorCode; + +@Component +@RequiredArgsConstructor +public class AuthorizedMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthorizedMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + Member authorizedMember = (Member) loginMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + if (authorizedMember == null || !authorizedMember.isMember()) { + throw MemberErrorCode.NOT_AUTHORIZED_MEMBER.toException(); + } + return authorizedMember; + } +} diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIp.java b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIp.java new file mode 100644 index 000000000..47e720434 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIp.java @@ -0,0 +1,11 @@ +package spring.backend.core.configuration.argumentresolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface ClientIp { +} diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIpArgumentResolver.java b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIpArgumentResolver.java new file mode 100644 index 000000000..d04f6d43a --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/argumentresolver/ClientIpArgumentResolver.java @@ -0,0 +1,69 @@ +package spring.backend.core.configuration.argumentresolver; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.log4j.Log4j2; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.stream.Stream; + +@Component +@Log4j2 +public class ClientIpArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String[] IP_HEADER_CANDIDATES = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR", + "X-Real-IP" + }; + + private static final String LOCAL_HOST_IPV6 = "0:0:0:0:0:0:0:1"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ClientIp.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = ((ServletWebRequest)webRequest).getRequest(); + return extractIpFromServlet(request); + } + + private String extractIpFromServlet(HttpServletRequest request) { + String ipList = Stream.of( IP_HEADER_CANDIDATES) + .map(request::getHeader) + .filter(header -> header != null && !header.isEmpty()) + .findFirst() + .orElseGet(request::getRemoteAddr); + String ip = getIpFromIpList(ipList); + + return ip; + } + + private String getIpFromIpList(String ipList) { + if (ipList.contains(",")) { + return Stream.of(ipList.split(",")) + .map(String::trim) + .filter(ip -> !ip.equalsIgnoreCase("unknown")) + .findFirst() + .orElse(""); + + } + + if (ipList.equals(LOCAL_HOST_IPV6)) { + return "127.0.0.1"; + } + + return ipList; + } +} + diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMember.java b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMember.java new file mode 100644 index 000000000..4b180cc29 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMember.java @@ -0,0 +1,11 @@ +package spring.backend.core.configuration.argumentresolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginMember { +} diff --git a/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolver.java b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..16ccd064a --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolver.java @@ -0,0 +1,80 @@ +package spring.backend.core.configuration.argumentresolver; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import spring.backend.core.application.JwtService; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.exception.MemberErrorCode; + +import java.util.Arrays; +import java.util.Optional; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + + public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + String token = extractToken(webRequest); + if (token == null) { + log.error("토큰이 존재하지 않습니다."); + throw new IllegalArgumentException("토큰이 존재하지 않습니다."); + } + + UUID memberId = jwtService.extractMemberId(token); + Member member = memberRepository.findById(memberId); + return Optional.ofNullable(member).orElseThrow(MemberErrorCode.NOT_EXIST_MEMBER::toException); + } + + private String extractToken(NativeWebRequest request) { + HttpServletRequest httpRequest = request.getNativeRequest(HttpServletRequest.class); + + if (httpRequest != null) { + if (isSwaggerRequest(httpRequest)) { + String authHeader = httpRequest.getHeader(AUTHORIZATION_HEADER); + if (authHeader != null && authHeader.startsWith(AUTHORIZATION_BEARER_PREFIX)) { + return authHeader.substring(7); + } + } + + Cookie[] cookies = httpRequest.getCookies(); + if (cookies != null) { + return Arrays.stream(cookies) + .filter(cookie -> "access_token".equals(cookie.getName())) + .findFirst() + .map(Cookie::getValue) + .orElse(null); + } + } + return null; + } + + private boolean isSwaggerRequest(HttpServletRequest request) { + String referer = request.getHeader("Referer"); + return referer != null && referer.contains("/swagger-ui"); + } +} diff --git a/src/main/java/spring/backend/core/configuration/email/MailConfiguration.java b/src/main/java/spring/backend/core/configuration/email/MailConfiguration.java new file mode 100644 index 000000000..74d5fe0a9 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/email/MailConfiguration.java @@ -0,0 +1,50 @@ +package spring.backend.core.configuration.email; + +import lombok.RequiredArgsConstructor; +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 +@RequiredArgsConstructor +public class MailConfiguration { + + @Value("${spring.mail.host}") + private String host; + + @Value("${spring.mail.username}") + private String username; + + @Value("${spring.mail.password}") + private String password; + + @Value("${spring.mail.port}") + private int port; + + @Value("${spring.mail.properties.mail.smtp.auth}") + private String smtpAuth; + + @Value("${spring.mail.properties.mail.debug}") + private String debug; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(port); + mailSender.setUsername(username); + mailSender.setPassword(password); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", smtpAuth); + props.put("mail.smtp.starttls.enable", smtpAuth); + props.put("mail.debug", debug); + + return mailSender; + } +} diff --git a/src/main/java/spring/backend/core/configuration/interceptor/Authorization.java b/src/main/java/spring/backend/core/configuration/interceptor/Authorization.java new file mode 100644 index 000000000..1fc418e04 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/interceptor/Authorization.java @@ -0,0 +1,11 @@ +package spring.backend.core.configuration.interceptor; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Authorization { +} diff --git a/src/main/java/spring/backend/core/configuration/interceptor/AuthorizationInterceptor.java b/src/main/java/spring/backend/core/configuration/interceptor/AuthorizationInterceptor.java new file mode 100644 index 000000000..8aa052f28 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/interceptor/AuthorizationInterceptor.java @@ -0,0 +1,75 @@ +package spring.backend.core.configuration.interceptor; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.core.application.JwtService; + +import java.util.Arrays; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class AuthorizationInterceptor implements HandlerInterceptor { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + + public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + + private static final List PASS_THROUGH_PATTERNS = Arrays.asList( + "/swagger-ui", "/v3/api-docs", "/v1/oauth", "/v1/token/rotate" + ); + + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + + if (isPassThroughRequest(request.getRequestURI())) { + return true; + } + String accessToken = extractToken(request); + log.info("accessToken: {}", accessToken); + if (accessToken == null) { + log.error("쿠키에 토큰이 존재하지 않습니다."); + throw AuthenticationErrorCode.NOT_EXIST_TOKEN_In_COOKIE.toException(); + } + jwtService.validateTokenExpiration(accessToken); + return true; + } + + private boolean isPassThroughRequest(String uri) { + return PASS_THROUGH_PATTERNS.stream().anyMatch(uri::contains); + } + + private String extractToken(HttpServletRequest request) { + if (isSwaggerRequest(request)) { + String authHeader = request.getHeader(AUTHORIZATION_HEADER); + if (authHeader != null && authHeader.startsWith(AUTHORIZATION_BEARER_PREFIX)) { + return authHeader.substring(7); + } + } + + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("access_token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + private boolean isSwaggerRequest(HttpServletRequest request) { + String referer = request.getHeader("Referer"); + return referer != null && referer.contains("/swagger-ui"); + } +} diff --git a/src/main/java/spring/backend/core/configuration/property/ImageProperty.java b/src/main/java/spring/backend/core/configuration/property/ImageProperty.java new file mode 100644 index 000000000..3b679c6f5 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/property/ImageProperty.java @@ -0,0 +1,27 @@ +package spring.backend.core.configuration.property; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@Getter +@Setter +@ConfigurationProperties("image") +public class ImageProperty { + + private String selfDevelopmentImageUrl; + private String healthImageUrl; + private String cultureArtImageUrl; + private String entertainmentImageUrl; + private String relaxationImageUrl; + private String socialImageUrl; + + private String transparent30SelfDevelopmentImageUrl; + private String transparent30HealthImageUrl; + private String transparent30CultureArtImageUrl; + private String transparent30EntertainmentImageUrl; + private String transparent30RelaxationImageUrl; + private String transparent30SocialImageUrl; +} diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/GoogleOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/GoogleOAuthProperty.java new file mode 100644 index 000000000..29a35f815 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/property/oauth/GoogleOAuthProperty.java @@ -0,0 +1,14 @@ +package spring.backend.core.configuration.property.oauth; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import spring.backend.core.configuration.property.oauth.shared.BaseOAuthProperty; + +@Component +@Getter +@Setter +@ConfigurationProperties("oauth2.google") +public class GoogleOAuthProperty extends BaseOAuthProperty { +} diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/KakaoOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/KakaoOAuthProperty.java new file mode 100644 index 000000000..e316cd3fa --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/property/oauth/KakaoOAuthProperty.java @@ -0,0 +1,14 @@ +package spring.backend.core.configuration.property.oauth; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import spring.backend.core.configuration.property.oauth.shared.BaseOAuthProperty; + +@Component +@Getter +@Setter +@ConfigurationProperties("oauth2.kakao") +public class KakaoOAuthProperty extends BaseOAuthProperty { +} diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/NaverOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/NaverOAuthProperty.java new file mode 100644 index 000000000..c7445eb41 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/property/oauth/NaverOAuthProperty.java @@ -0,0 +1,14 @@ +package spring.backend.core.configuration.property.oauth; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import spring.backend.core.configuration.property.oauth.shared.BaseOAuthProperty; + +@Component +@Getter +@Setter +@ConfigurationProperties("oauth2.naver") +public class NaverOAuthProperty extends BaseOAuthProperty { +} diff --git a/src/main/java/spring/backend/core/configuration/property/oauth/shared/BaseOAuthProperty.java b/src/main/java/spring/backend/core/configuration/property/oauth/shared/BaseOAuthProperty.java new file mode 100644 index 000000000..ce10e3901 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/property/oauth/shared/BaseOAuthProperty.java @@ -0,0 +1,25 @@ +package spring.backend.core.configuration.property.oauth.shared; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Set; + +@Getter +@Setter +public class BaseOAuthProperty { + + protected String clientId; + + protected String clientSecret; + + protected String redirectUri; + + protected Set scope; + + protected String tokenUri; + + protected String resourceUri; + + protected String authUri; +} diff --git a/src/main/java/spring/backend/core/configuration/property/queue/FinishActivityQueueProperty.java b/src/main/java/spring/backend/core/configuration/property/queue/FinishActivityQueueProperty.java new file mode 100644 index 000000000..7fdd12d79 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/property/queue/FinishActivityQueueProperty.java @@ -0,0 +1,10 @@ +package spring.backend.core.configuration.property.queue; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import spring.backend.core.configuration.property.queue.shared.BaseQueueProperty; + +@Component +@ConfigurationProperties("finish-activity-queue") +public class FinishActivityQueueProperty extends BaseQueueProperty { +} diff --git a/src/main/java/spring/backend/core/configuration/property/queue/shared/BaseQueueProperty.java b/src/main/java/spring/backend/core/configuration/property/queue/shared/BaseQueueProperty.java new file mode 100644 index 000000000..352ecede3 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/property/queue/shared/BaseQueueProperty.java @@ -0,0 +1,15 @@ +package spring.backend.core.configuration.property.queue.shared; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class BaseQueueProperty { + + protected String exchange; + + protected String queue; + + protected String routingKey; +} diff --git a/src/main/java/spring/backend/core/configuration/redis/RedisConfiguration.java b/src/main/java/spring/backend/core/configuration/redis/RedisConfiguration.java new file mode 100644 index 000000000..f195716a4 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/redis/RedisConfiguration.java @@ -0,0 +1,29 @@ +package spring.backend.core.configuration.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +@Configuration +public class RedisConfiguration { + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port)); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} diff --git a/src/main/java/spring/backend/core/configuration/swagger/ApiErrorCode.java b/src/main/java/spring/backend/core/configuration/swagger/ApiErrorCode.java new file mode 100644 index 000000000..d34b2faab --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/swagger/ApiErrorCode.java @@ -0,0 +1,14 @@ +package spring.backend.core.configuration.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import spring.backend.core.exception.error.BaseErrorCode; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorCode { + + Class[] value(); +} \ No newline at end of file diff --git a/src/main/java/spring/backend/core/configuration/swagger/ExampleHolder.java b/src/main/java/spring/backend/core/configuration/swagger/ExampleHolder.java new file mode 100644 index 000000000..78d454c67 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/swagger/ExampleHolder.java @@ -0,0 +1,16 @@ +package spring.backend.core.configuration.swagger; + +import io.swagger.v3.oas.models.examples.Example; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExampleHolder { + + private Example holder; + + private int code; + + private String name; +} diff --git a/src/main/java/spring/backend/core/configuration/swagger/SwaggerConfiguration.java b/src/main/java/spring/backend/core/configuration/swagger/SwaggerConfiguration.java new file mode 100644 index 000000000..e7f9c56a4 --- /dev/null +++ b/src/main/java/spring/backend/core/configuration/swagger/SwaggerConfiguration.java @@ -0,0 +1,101 @@ +package spring.backend.core.configuration.swagger; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import io.swagger.v3.oas.models.security.SecurityScheme.Type; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.HandlerMethod; +import spring.backend.core.exception.error.BaseErrorCode; +import spring.backend.core.presentation.ErrorResponse; + +import java.util.*; +import java.util.stream.Collectors; + +@Configuration +@OpenAPIDefinition(info = @Info(title = "조각조각 API", description = "조각조각 : API 명세서", version = "v1.0.0"), + servers = {@Server(url = "${springdoc.server-url}", description = "Https Server URL")}) +public class SwaggerConfiguration { + + @Bean + public OpenAPI openAPI(){ + SecurityScheme securityScheme = new SecurityScheme() + .type(Type.HTTP).scheme("bearer").bearerFormat("JWT") + .in(In.HEADER).name("Authorization"); + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) + .security(Arrays.asList(securityRequirement)); + } + + @Bean + public OperationCustomizer operationCustomizer() { + return (Operation operation, HandlerMethod handlerMethod) -> { + ApiErrorCode apiErrorCode = handlerMethod.getMethodAnnotation(ApiErrorCode.class); + if (apiErrorCode != null) { + generateErrorCodeResponseExample(operation, apiErrorCode.value()); + } + return operation; + }; + } + + private void generateErrorCodeResponseExample(Operation operation, Class[] types) { + ApiResponses responses = operation.getResponses(); + List exampleHolders = new ArrayList<>(); + + for (Class type : types) { + BaseErrorCode[] errorCodes = type.getEnumConstants(); + Arrays.stream(errorCodes).map( + baseErrorCode -> ExampleHolder.builder() + .holder(getSwaggerExample(baseErrorCode)) + .code(baseErrorCode.getHttpStatus().value()) + .name(baseErrorCode.name()) + .build() + ).forEach(exampleHolders::add); + } + + Map> statusWithExampleHolders = new HashMap<>( + exampleHolders.stream() + .collect(Collectors.groupingBy(ExampleHolder::getCode))); + + addExamplesToResponses(responses, statusWithExampleHolders); + } + + private Example getSwaggerExample(BaseErrorCode baseErrorCode) { + ErrorResponse errorResponse = ErrorResponse.createSwaggerErrorResponse() + .baseErrorCode(baseErrorCode) + .build(); + Example example = new Example(); + example.setValue(errorResponse); + return example; + } + + private void addExamplesToResponses(ApiResponses responses, Map> statusWithExampleHolders) { + statusWithExampleHolders.forEach( + (status, value) -> { + Content content = new Content(); + MediaType mediaType = new MediaType(); + ApiResponse apiResponse = new ApiResponse(); + value.forEach(exampleHolder -> mediaType.addExamples(exampleHolder.getName(), + exampleHolder.getHolder())); + content.addMediaType("application/json", mediaType); + apiResponse.setContent(content); + responses.addApiResponse(status.toString(), apiResponse); + } + ); + } +} diff --git a/src/main/java/spring/backend/core/converter/ImageConverter.java b/src/main/java/spring/backend/core/converter/ImageConverter.java new file mode 100644 index 000000000..cf8a8be29 --- /dev/null +++ b/src/main/java/spring/backend/core/converter/ImageConverter.java @@ -0,0 +1,53 @@ +package spring.backend.core.converter; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import spring.backend.activity.domain.value.Keyword.Category; +import spring.backend.core.configuration.property.ImageProperty; + +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class ImageConverter { + + private final ImageProperty imageProperty; + private final Map categoryImageMap = new HashMap<>(); + private final Map categoryTransparent30ImageMap = new HashMap<>(); + + @PostConstruct + private void initializeImageMap() { + categoryImageMap.put(Category.SELF_DEVELOPMENT, imageProperty.getSelfDevelopmentImageUrl()); + categoryImageMap.put(Category.HEALTH, imageProperty.getHealthImageUrl()); + categoryImageMap.put(Category.CULTURE_ART, imageProperty.getCultureArtImageUrl()); + categoryImageMap.put(Category.ENTERTAINMENT, imageProperty.getEntertainmentImageUrl()); + categoryImageMap.put(Category.RELAXATION, imageProperty.getRelaxationImageUrl()); + categoryImageMap.put(Category.SOCIAL, imageProperty.getSocialImageUrl()); + } + + @PostConstruct + private void initializeTransparent30ImageMap() { + categoryTransparent30ImageMap.put(Category.SELF_DEVELOPMENT, imageProperty.getTransparent30SelfDevelopmentImageUrl()); + categoryTransparent30ImageMap.put(Category.HEALTH, imageProperty.getTransparent30HealthImageUrl()); + categoryTransparent30ImageMap.put(Category.CULTURE_ART, imageProperty.getTransparent30CultureArtImageUrl()); + categoryTransparent30ImageMap.put(Category.ENTERTAINMENT, imageProperty.getTransparent30EntertainmentImageUrl()); + categoryTransparent30ImageMap.put(Category.RELAXATION, imageProperty.getTransparent30RelaxationImageUrl()); + categoryTransparent30ImageMap.put(Category.SOCIAL, imageProperty.getTransparent30SocialImageUrl()); + } + + public String convertToImageUrl(Category category) { + if (category == null) { + return null; + } + return categoryImageMap.get(category); + } + + public String convertToTransparent30ImageUrl(Category category) { + if (category == null) { + return null; + } + return categoryTransparent30ImageMap.get(category); + } +} diff --git a/src/main/java/spring/backend/core/exception/DomainException.java b/src/main/java/spring/backend/core/exception/DomainException.java new file mode 100644 index 000000000..a1689ec0b --- /dev/null +++ b/src/main/java/spring/backend/core/exception/DomainException.java @@ -0,0 +1,28 @@ +package spring.backend.core.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +public class DomainException extends RuntimeException { + + private HttpStatus httpStatus; + + private String code; + + public DomainException(String message) { + super(message); + } + + public DomainException(HttpStatus httpStatus, String message) { + super(message); + this.httpStatus = httpStatus; + } + + public DomainException(HttpStatus httpStatus, BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.httpStatus = httpStatus; + this.code = errorCode.name(); + } +} diff --git a/src/main/java/spring/backend/core/exception/GlobalExceptionHandler.java b/src/main/java/spring/backend/core/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..3d2d07013 --- /dev/null +++ b/src/main/java/spring/backend/core/exception/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +package spring.backend.core.exception; + +import io.sentry.Sentry; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import spring.backend.core.presentation.ErrorResponse; + +import java.util.Optional; + +@RestControllerAdvice +@Log4j2 +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler(Exception.class) + public final ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { + log.error("ERROR ::: [AllException] ", ex); + Sentry.captureException(ex); + ErrorResponse errorResponse = ErrorResponse.createErrorResponse().statusCode(500).exception(ex).build(); + return ResponseEntity.internalServerError().body(errorResponse); + } + + @ExceptionHandler(DomainException.class) + public final ResponseEntity handleDomainException(DomainException ex) { + HttpStatus httpStatus = Optional.ofNullable(ex.getHttpStatus()).orElse(HttpStatus.INTERNAL_SERVER_ERROR); + log.error("ERROR ::: [DomainException] ", ex); + Sentry.captureException(ex); + ErrorResponse errorResponse = ErrorResponse.createDomainErrorResponse().statusCode(httpStatus.value()).exception(ex).build(); + return ResponseEntity.status(httpStatus).body(errorResponse); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + log.error("ERROR ::: [MethodArgumentNotValidException] ", ex); + Sentry.captureException(ex); + ErrorResponse errorResponse = ErrorResponse.createValidationErrorResponse().statusCode(400).exception(ex).build(); + return ResponseEntity.badRequest().body(errorResponse); + } +} diff --git a/src/main/java/spring/backend/core/exception/error/BaseErrorCode.java b/src/main/java/spring/backend/core/exception/error/BaseErrorCode.java new file mode 100644 index 000000000..1b6663194 --- /dev/null +++ b/src/main/java/spring/backend/core/exception/error/BaseErrorCode.java @@ -0,0 +1,14 @@ +package spring.backend.core.exception.error; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + + String name(); + + String getMessage(); + + HttpStatus getHttpStatus(); + + T toException(); +} diff --git a/src/main/java/spring/backend/core/exception/error/GlobalErrorCode.java b/src/main/java/spring/backend/core/exception/error/GlobalErrorCode.java new file mode 100644 index 000000000..de06af23b --- /dev/null +++ b/src/main/java/spring/backend/core/exception/error/GlobalErrorCode.java @@ -0,0 +1,23 @@ +package spring.backend.core.exception.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum GlobalErrorCode implements BaseErrorCode { + + ALREADY_PROCESS_STARTED(HttpStatus.BAD_REQUEST, "이미 처리중인 요청입니다."), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 내부 오류입니다."), + REDIS_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis 서버와의 연결에 문제가 발생했습니다."), + WEB_CLIENT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WebClient 오류가 발생했습니다."); + private final HttpStatus httpStatus; + + private final String message; + + @Override + public RuntimeException toException() { + return new RuntimeException(message); + } +} diff --git a/src/main/java/spring/backend/core/infrastructure/jpa/shared/BaseEntity.java b/src/main/java/spring/backend/core/infrastructure/jpa/shared/BaseEntity.java new file mode 100644 index 000000000..753fed82f --- /dev/null +++ b/src/main/java/spring/backend/core/infrastructure/jpa/shared/BaseEntity.java @@ -0,0 +1,33 @@ +package spring.backend.core.infrastructure.jpa.shared; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import spring.backend.core.infrastructure.persistence.SequentialUUID; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@MappedSuperclass +@EntityListeners(value = AuditingEntityListener.class) +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BaseEntity { + + @Id + @SequentialUUID + protected UUID id; + + @CreatedDate + protected LocalDateTime createdAt; + + @LastModifiedDate + protected LocalDateTime updatedAt; + + @Builder.Default + protected Boolean deleted = false; +} diff --git a/src/main/java/spring/backend/core/infrastructure/jpa/shared/BaseLongIdEntity.java b/src/main/java/spring/backend/core/infrastructure/jpa/shared/BaseLongIdEntity.java new file mode 100644 index 000000000..1645d0e26 --- /dev/null +++ b/src/main/java/spring/backend/core/infrastructure/jpa/shared/BaseLongIdEntity.java @@ -0,0 +1,34 @@ +package spring.backend.core.infrastructure.jpa.shared; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +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(value = AuditingEntityListener.class) +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BaseLongIdEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Long id; + + @CreatedDate + protected LocalDateTime createdAt; + + @LastModifiedDate + protected LocalDateTime updatedAt; + + @Builder.Default + protected Boolean deleted = false; +} \ No newline at end of file diff --git a/src/main/java/spring/backend/core/infrastructure/persistence/SequentialUUID.java b/src/main/java/spring/backend/core/infrastructure/persistence/SequentialUUID.java new file mode 100644 index 000000000..e56674f1e --- /dev/null +++ b/src/main/java/spring/backend/core/infrastructure/persistence/SequentialUUID.java @@ -0,0 +1,14 @@ +package spring.backend.core.infrastructure.persistence; + +import org.hibernate.annotations.IdGeneratorType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@IdGeneratorType(SequentialUUIDGenerator.class) +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface SequentialUUID { +} diff --git a/src/main/java/spring/backend/core/infrastructure/persistence/SequentialUUIDGenerator.java b/src/main/java/spring/backend/core/infrastructure/persistence/SequentialUUIDGenerator.java new file mode 100644 index 000000000..82d1bfe15 --- /dev/null +++ b/src/main/java/spring/backend/core/infrastructure/persistence/SequentialUUIDGenerator.java @@ -0,0 +1,19 @@ +package spring.backend.core.infrastructure.persistence; + +import org.hibernate.HibernateException; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.id.IdentifierGenerator; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +public class SequentialUUIDGenerator implements IdentifierGenerator { + + @Override + public Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException { + long mostSignificantBits = Instant.now().toEpochMilli(); + long leastSignificantBits = UUID.randomUUID().getLeastSignificantBits(); + return new UUID(mostSignificantBits, leastSignificantBits); + } +} diff --git a/src/main/java/spring/backend/core/infrastructure/queue/MessageConsumer.java b/src/main/java/spring/backend/core/infrastructure/queue/MessageConsumer.java new file mode 100644 index 000000000..eb7ed1913 --- /dev/null +++ b/src/main/java/spring/backend/core/infrastructure/queue/MessageConsumer.java @@ -0,0 +1,6 @@ +package spring.backend.core.infrastructure.queue; + +public interface MessageConsumer { + + void consumeMessage(T message); +} diff --git a/src/main/java/spring/backend/core/infrastructure/queue/MessageProducer.java b/src/main/java/spring/backend/core/infrastructure/queue/MessageProducer.java new file mode 100644 index 000000000..6f6f637b2 --- /dev/null +++ b/src/main/java/spring/backend/core/infrastructure/queue/MessageProducer.java @@ -0,0 +1,42 @@ +package spring.backend.core.infrastructure.queue; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import spring.backend.core.configuration.ApplicationContextProvider; +import spring.backend.core.configuration.property.queue.shared.BaseQueueProperty; + +@RequiredArgsConstructor +@Log4j2 +public abstract class MessageProducer { + + private static final RabbitTemplate rabbitTemplate = ApplicationContextProvider.getBean("rabbitTemplate", RabbitTemplate.class); + + protected final T queueProperty; + + protected final R message; + + public void publishMessage() { + try { + rabbitTemplate.convertAndSend(queueProperty.getExchange(), queueProperty.getRoutingKey(), message); + } catch (Exception e) { + log.error("[MessagePublisher] - publishMessage Failed", e); + } + } + + public void publishMessageWithDelay(long delayTime) { + try { + rabbitTemplate.convertAndSend( + queueProperty.getExchange(), + queueProperty.getRoutingKey(), + message, + messagePostProcessor -> { + messagePostProcessor.getMessageProperties().setDelayLong(delayTime); + return messagePostProcessor; + } + ); + } catch (Exception e) { + log.error("[MessagePublisher] - publishMessageWithDelay Failed", e); + } + } +} diff --git a/src/main/java/spring/backend/core/presentation/BaseResponse.java b/src/main/java/spring/backend/core/presentation/BaseResponse.java new file mode 100644 index 000000000..8ccb569a9 --- /dev/null +++ b/src/main/java/spring/backend/core/presentation/BaseResponse.java @@ -0,0 +1,15 @@ +package spring.backend.core.presentation; + +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseResponse { + + private final boolean success; + + private final LocalDateTime timestamp; +} diff --git a/src/main/java/spring/backend/core/presentation/ErrorResponse.java b/src/main/java/spring/backend/core/presentation/ErrorResponse.java new file mode 100644 index 000000000..404801b17 --- /dev/null +++ b/src/main/java/spring/backend/core/presentation/ErrorResponse.java @@ -0,0 +1,51 @@ +package spring.backend.core.presentation; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.web.bind.MethodArgumentNotValidException; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +import java.time.LocalDateTime; + +@Getter +public class ErrorResponse extends BaseResponse { + + private final int statusCode; + + private final String code; + + private final String message; + + @Builder(builderClassName = "CreateErrorResponse", builderMethodName = "createErrorResponse") + public ErrorResponse(int statusCode, Exception exception) { + super(false, LocalDateTime.now()); + this.statusCode = statusCode; + this.code = exception.getClass().getSimpleName(); + this.message = exception.getMessage(); + } + + @Builder(builderClassName = "CreateDomainErrorResponse", builderMethodName = "createDomainErrorResponse") + public ErrorResponse(int statusCode, DomainException exception) { + super(false, LocalDateTime.now()); + this.statusCode = statusCode; + this.code = exception.getCode(); + this.message = exception.getMessage(); + } + + @Builder(builderClassName = "CreateSwaggerErrorResponse", builderMethodName = "createSwaggerErrorResponse") + public ErrorResponse(BaseErrorCode baseErrorCode) { + super(false, LocalDateTime.now()); + this.statusCode = baseErrorCode.getHttpStatus().value(); + this.code = baseErrorCode.name(); + this.message = baseErrorCode.getMessage(); + } + + @Builder(builderClassName = "CreateValidationErrorResponse", builderMethodName = "createValidationErrorResponse") + public ErrorResponse(int statusCode, MethodArgumentNotValidException exception) { + super(false, LocalDateTime.now()); + this.statusCode = statusCode; + this.code = exception.getClass().getSimpleName(); + this.message = exception.getBindingResult().getFieldErrors().get(0).getDefaultMessage(); + } +} diff --git a/src/main/java/spring/backend/core/presentation/HealthController.java b/src/main/java/spring/backend/core/presentation/HealthController.java new file mode 100644 index 000000000..031a854f6 --- /dev/null +++ b/src/main/java/spring/backend/core/presentation/HealthController.java @@ -0,0 +1,12 @@ +package spring.backend.core.presentation; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + @GetMapping("/health") + public String health() { + return "OK"; + } +} diff --git a/src/main/java/spring/backend/core/presentation/RestResponse.java b/src/main/java/spring/backend/core/presentation/RestResponse.java new file mode 100644 index 000000000..e5cf97b80 --- /dev/null +++ b/src/main/java/spring/backend/core/presentation/RestResponse.java @@ -0,0 +1,15 @@ +package spring.backend.core.presentation; + +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class RestResponse extends BaseResponse { + + private final T data; + + public RestResponse(T data) { + super(true, LocalDateTime.now()); + this.data = data; + } +} diff --git a/src/main/java/spring/backend/core/util/TimeUtil.java b/src/main/java/spring/backend/core/util/TimeUtil.java new file mode 100644 index 000000000..7c962e7e6 --- /dev/null +++ b/src/main/java/spring/backend/core/util/TimeUtil.java @@ -0,0 +1,62 @@ +package spring.backend.core.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TimeUtil { + + private static final String ANTE_MERIDIEM = "오전"; + + private static final String POST_MERIDIEM = "오후"; + + public static LocalTime toLocalTime(String meridiem, Integer hour, Integer minute) { + if (meridiem == null || hour == null || minute == null) { + return null; + } + try { + return switch (meridiem) { + case ANTE_MERIDIEM -> LocalTime.of(hour % 12, minute); + case POST_MERIDIEM -> LocalTime.of((hour % 12) + 12, minute); + default -> null; + }; + } catch (DateTimeException e) { + return null; + } + } + + public static String toMeridiem(LocalTime time) { + if (time == null) { + return null; + } + return time.getHour() < 12 ? ANTE_MERIDIEM : POST_MERIDIEM; + } + + public static Integer toHour(LocalTime time) { + if (time == null) { + return null; + } + int hour = time.getHour() % 12; + return hour == 0 ? 12 : hour; + } + + public static Integer toMinute(LocalTime time) { + if (time == null) { + return null; + } + return time.getMinute(); + } + + public static LocalDateTime toStartDayOfMonth(YearMonth yearMonth) { + return yearMonth.atDay(1).atStartOfDay(); + } + + public static LocalDateTime toEndDayOfMonth(YearMonth yearMonth) { + return yearMonth.atEndOfMonth().atTime(23, 59, 59); + } +} diff --git a/src/main/java/spring/backend/core/util/email/EmailUtil.java b/src/main/java/spring/backend/core/util/email/EmailUtil.java new file mode 100644 index 000000000..60366a808 --- /dev/null +++ b/src/main/java/spring/backend/core/util/email/EmailUtil.java @@ -0,0 +1,92 @@ +package spring.backend.core.util.email; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailAuthenticationException; +import org.springframework.mail.MailException; +import org.springframework.mail.MailParseException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import spring.backend.core.util.email.dto.request.SendEmailRequest; +import spring.backend.core.util.email.exception.MailErrorCode; + +import java.util.regex.Pattern; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class EmailUtil { + @Value("${spring.mail.sender}") + private String sender; + + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + ); + + private final JavaMailSender mailSender; + + @Async("mailExecutor") + public void send(SendEmailRequest sendEmailRequest) { + validateEmailRequest(sendEmailRequest); + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(sender); + helper.setTo(sendEmailRequest.receiver()); + helper.setSubject(sendEmailRequest.title()); + helper.setText(sendEmailRequest.content(), true); + + mailSender.send(message); + } catch (MailParseException e) { + log.error("[EmailUtil] Failed to parse email for recipient: {}, subject: {}. Error: {}", + sendEmailRequest.receiver(), sendEmailRequest.title(), e.getMessage()); + throw MailErrorCode.FAILED_TO_PARSE_MAIL.toException(); + } catch (MailAuthenticationException e) { + log.error("[EmailUtil] Authentication failed for email sender: {}. Error: {}", + sender, e.getMessage()); + throw MailErrorCode.AUTHENTICATION_FAILED.toException(); + } catch (MailSendException e) { + log.error("[EmailUtil] Error occurred while sending email to recipient: {}, subject: {}. Error: {}", + sendEmailRequest.receiver(), sendEmailRequest.title(), e.getMessage()); + throw MailErrorCode.ERROR_OCCURRED_SENDING_MAIL.toException(); + } catch (MailException | MessagingException e) { + log.error("[EmailUtil] General mail error for recipient: {}, subject: {}. Error: {}", + sendEmailRequest.receiver(), sendEmailRequest.title(), e.getMessage()); + throw MailErrorCode.GENERAL_MAIL_ERROR.toException(); + } + } + + private void validateEmailRequest(SendEmailRequest request) { + validateEmailAddress(request); + validateEmailSubject(request); + validateEmailText(request); + } + + private void validateEmailAddress(SendEmailRequest request) { + if (request.receiver() == null || !EMAIL_PATTERN.matcher(request.receiver()).matches()) { + log.error("[EmailUtil] Invalid email address format: {}", request.receiver()); + throw MailErrorCode.INVALID_MAIL_ADDRESS.toException(); + } + } + + private void validateEmailSubject(SendEmailRequest request) { + if (request.title() == null || request.title().isEmpty()) { + log.error("[EmailUtil] Invalid email title: {}", request.title()); + throw MailErrorCode.NO_MAIL_TITLE.toException(); + } + } + + private void validateEmailText(SendEmailRequest request) { + if (request.content() == null || request.content().isEmpty()) { + log.error("[EmailUtil] Invalid email subject: {}", request.title()); + throw MailErrorCode.NO_MAIL_CONTENT.toException(); + } + } +} diff --git a/src/main/java/spring/backend/core/util/email/dto/request/SendEmailRequest.java b/src/main/java/spring/backend/core/util/email/dto/request/SendEmailRequest.java new file mode 100644 index 000000000..a60e56412 --- /dev/null +++ b/src/main/java/spring/backend/core/util/email/dto/request/SendEmailRequest.java @@ -0,0 +1,11 @@ +package spring.backend.core.util.email.dto.request; + +import lombok.Builder; + +@Builder +public record SendEmailRequest( + String receiver, + String title, + String content +) { +} diff --git a/src/main/java/spring/backend/core/util/email/exception/MailErrorCode.java b/src/main/java/spring/backend/core/util/email/exception/MailErrorCode.java new file mode 100644 index 000000000..a930c99b0 --- /dev/null +++ b/src/main/java/spring/backend/core/util/email/exception/MailErrorCode.java @@ -0,0 +1,29 @@ +package spring.backend.core.util.email.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum MailErrorCode implements BaseErrorCode { + + FAILED_TO_PARSE_MAIL(HttpStatus.BAD_REQUEST, "메일 파싱 중 오류가 발생했습니다."), + NO_MAIL_CONTENT(HttpStatus.BAD_REQUEST, "메일 내용이 없습니다."), + NO_MAIL_TITLE(HttpStatus.BAD_REQUEST, "메일 제목이 없습니다."), + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "메일 서버 인증에 실패했습니다."), + ERROR_OCCURRED_SENDING_MAIL(HttpStatus.INTERNAL_SERVER_ERROR, "메일 전송 중 오류가 발생했습니다."), + GENERAL_MAIL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "메일 처리 중 예기치 않은 오류가 발생했습니다."), + INVALID_MAIL_ADDRESS(HttpStatus.BAD_REQUEST, "올바르지 않은 이메일 주소입니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/core/util/geo/GeoUtil.java b/src/main/java/spring/backend/core/util/geo/GeoUtil.java new file mode 100644 index 000000000..9038ccda7 --- /dev/null +++ b/src/main/java/spring/backend/core/util/geo/GeoUtil.java @@ -0,0 +1,22 @@ +package spring.backend.core.util.geo; + +public class GeoUtil { + private static final double EARTH_RADIUS = 6371; + + public static double calculateDistanceBetweenTwoCoordinate(double savedLat, double savedLon, double newLat, double newLon) { + double deltaLatDiff = Math.toRadians(Math.abs(newLat - savedLat)); + double deltaLonDiff = Math.toRadians(Math.abs(newLon - savedLon)); + + double sinDeltaLatDiff = Math.sin(deltaLatDiff / 2); + double sinDeltaLonDiff = Math.sin(deltaLonDiff / 2); + + double squareRoot = Math.sqrt( + sinDeltaLatDiff * sinDeltaLatDiff + + (Math.cos(Math.toRadians(savedLat)) + * Math.cos(Math.toRadians(newLat)) + * sinDeltaLonDiff * sinDeltaLonDiff) + ); + + return 2 * EARTH_RADIUS * Math.asin(squareRoot); + } +} diff --git a/src/main/java/spring/backend/core/util/geo/dto/response/Coordinate.java b/src/main/java/spring/backend/core/util/geo/dto/response/Coordinate.java new file mode 100644 index 000000000..a744f31e2 --- /dev/null +++ b/src/main/java/spring/backend/core/util/geo/dto/response/Coordinate.java @@ -0,0 +1,10 @@ +package spring.backend.core.util.geo.dto.response; + +public record Coordinate( + double latitude, + double longitude +) { + public static Coordinate of(double latitude, double longitude) { + return new Coordinate(latitude, longitude); + } +} diff --git a/src/main/java/spring/backend/member/application/ReadMemberHomeService.java b/src/main/java/spring/backend/member/application/ReadMemberHomeService.java new file mode 100644 index 000000000..5da0d7dca --- /dev/null +++ b/src/main/java/spring/backend/member/application/ReadMemberHomeService.java @@ -0,0 +1,56 @@ +package spring.backend.member.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.activity.dto.response.HomeActivityInfoResponse; +import spring.backend.quickstart.dto.response.QuickStartResponse; +import spring.backend.activity.query.dao.ActivityDao; +import spring.backend.quickstart.query.dao.QuickStartDao; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.presentation.dto.response.HomeMainResponse; +import spring.backend.member.dto.response.HomeMemberInfoResponse; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional(readOnly = true) +public class ReadMemberHomeService { + + private final ActivityDao activityDao; + + private final QuickStartDao quickStartDao; + + public HomeMainResponse readMemberHome(Member member) { + HomeMemberInfoResponse memberInfo = HomeMemberInfoResponse.from(member); + + List upcomingQuickStarts = quickStartDao.findUpcomingQuickStarts(member.getId()); + QuickStartResponse upcomingQuickStart = upcomingQuickStarts.stream().findFirst().orElse(null); + + LocalDateTime currentDateTime = LocalDateTime.now(); + List activities = activityDao.findTodayActivities(member.getId(), currentDateTime.toLocalDate().atStartOfDay(), currentDateTime); + int totalSavedTime = calculateTotalSavedTime(activities); + + return HomeMainResponse.of(memberInfo, upcomingQuickStart, totalSavedTime, activities); + } + + private int calculateTotalSavedTime(List activities) { + if (activities == null || activities.isEmpty()) { + log.info("[ReadMemberHomeService] activities is empty"); + return 0; + } + return activities.stream() + .mapToInt(activity -> { + if (activity == null) { + log.info("[ReadMemberHomeService] activity is null"); + return 0; + } + return activity.savedTime(); + }) + .sum(); + } +} diff --git a/src/main/java/spring/backend/member/domain/entity/Member.java b/src/main/java/spring/backend/member/domain/entity/Member.java new file mode 100644 index 000000000..f8bae59ac --- /dev/null +++ b/src/main/java/spring/backend/member/domain/entity/Member.java @@ -0,0 +1,100 @@ +package spring.backend.member.domain.entity; + +import lombok.Builder; +import lombok.Getter; +import spring.backend.member.domain.value.Gender; +import spring.backend.member.domain.value.Provider; +import spring.backend.member.domain.value.Role; +import spring.backend.member.exception.MemberErrorCode; +import spring.backend.member.infrastructure.persistence.jpa.entity.MemberJpaEntity; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +public class Member { + + private UUID id; + + private Provider provider; + + private Role role; + + private String email; + + private String nickname; + + private int birthYear; + + private Gender gender; + + private String profileImage; + + @Builder.Default + private boolean emailNotification = true; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private Boolean deleted; + + public static Member toDomainEntity(MemberJpaEntity memberJpaEntity) { + return Member.builder() + .id(memberJpaEntity.getId()) + .provider(memberJpaEntity.getProvider()) + .role(memberJpaEntity.getRole()) + .email(memberJpaEntity.getEmail()) + .nickname(memberJpaEntity.getNickname()) + .birthYear(memberJpaEntity.getBirthYear()) + .gender(memberJpaEntity.getGender()) + .profileImage(memberJpaEntity.getProfileImage()) + .emailNotification(memberJpaEntity.isEmailNotification()) + .createdAt(memberJpaEntity.getCreatedAt()) + .updatedAt(memberJpaEntity.getUpdatedAt()) + .deleted(memberJpaEntity.getDeleted()) + .build(); + } + + public boolean isSameProvider(Provider otherProvider) { + return this.provider.equals(otherProvider); + } + + public boolean isMember() { + return Role.MEMBER.equals(this.role); + } + + public void convertGuestToMember(String nickname, int birthYear, Gender gender, String profileImage) { + if (isMember()) { + throw MemberErrorCode.ALREADY_REGISTERED_MEMBER.toException(); + } + this.role = Role.MEMBER; + this.nickname = nickname; + this.birthYear = birthYear; + this.gender = gender; + this.profileImage = profileImage; + } + + public void changeEmailNotification(boolean isEmailNotification) { + if (this.emailNotification == isEmailNotification) { + throw isEmailNotification + ? MemberErrorCode.ALREADY_ENABLE_EMAIL_NOTIFICATION.toException() + : MemberErrorCode.ALREADY_DISABLE_EMAIL_NOTIFICATION.toException(); + } + this.emailNotification = isEmailNotification; + } + + public static Member createGuestMember(Provider provider, String email) { + return Member.builder() + .provider(provider) + .role(Role.GUEST) + .email(email) + .build(); + } + + public void editMemberProfile(String nickname, String profileImage) { + this.nickname = nickname; + this.profileImage = profileImage; + } +} diff --git a/src/main/java/spring/backend/member/domain/repository/MemberRepository.java b/src/main/java/spring/backend/member/domain/repository/MemberRepository.java new file mode 100644 index 000000000..b4be90939 --- /dev/null +++ b/src/main/java/spring/backend/member/domain/repository/MemberRepository.java @@ -0,0 +1,18 @@ +package spring.backend.member.domain.repository; + +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.value.Role; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +public interface MemberRepository { + + Member findById(UUID id); + Member save(Member member); + Member findByEmail(String email); + List findAllByEmail(String email); + boolean existsByNicknameAndRole(String nickname, Role role); + List findMembersForQuickStartsInTimeRange(LocalTime lowerBound, LocalTime upperBound); +} diff --git a/src/main/java/spring/backend/member/domain/service/ChangeEmailNotificationService.java b/src/main/java/spring/backend/member/domain/service/ChangeEmailNotificationService.java new file mode 100644 index 000000000..502b05e97 --- /dev/null +++ b/src/main/java/spring/backend/member/domain/service/ChangeEmailNotificationService.java @@ -0,0 +1,21 @@ +package spring.backend.member.domain.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChangeEmailNotificationService { + + private final MemberRepository memberRepository; + + public void changeEmailNotification(Member member) { + boolean isEmailNotificationEnabled = member.isEmailNotification(); + member.changeEmailNotification(!isEmailNotificationEnabled); + memberRepository.save(member); + } +} diff --git a/src/main/java/spring/backend/member/domain/service/CreateMemberWithOAuthService.java b/src/main/java/spring/backend/member/domain/service/CreateMemberWithOAuthService.java new file mode 100644 index 000000000..f56210d9c --- /dev/null +++ b/src/main/java/spring/backend/member/domain/service/CreateMemberWithOAuthService.java @@ -0,0 +1,69 @@ +package spring.backend.member.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.presentation.dto.request.CreateMemberWithOAuthRequest; +import spring.backend.member.exception.MemberErrorCode; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class CreateMemberWithOAuthService { + + private final MemberRepository memberRepository; + + public Member createMemberWithOAuth(CreateMemberWithOAuthRequest request) { + if (request == null) { + log.error("[createMemberWithOAuth] request is null"); + throw MemberErrorCode.NOT_EXIST_CONDITION.toException(); + } + + List members = memberRepository.findAllByEmail(request.getEmail()); + if (members == null || members.isEmpty()) { + return createGuestMember(request); + } + + return handleExistingMember(members, request); + } + + private Member handleExistingMember(List members, CreateMemberWithOAuthRequest request) { + Member existingMember = members.stream() + .filter(Member::isMember) + .findFirst() + .orElse(null); + + if (existingMember != null) { + if (!existingMember.isSameProvider(request.getProvider())) { + log.error("[CreateMemberWithOAuthService] member already exists with a different provider [{}]", existingMember.getProvider()); + throw MemberErrorCode.ALREADY_REGISTERED_WITH_DIFFERENT_OAUTH2.toException(); + } + return existingMember; + } + + return members.stream() + .filter(m -> m.isSameProvider(request.getProvider())) + .findFirst() + .orElseGet(() -> createGuestMember(request)); + } + + private Member createGuestMember(CreateMemberWithOAuthRequest request) { + Member newMember = Member.createGuestMember(request.getProvider(), request.getEmail()); + return saveMember(newMember); + } + + private Member saveMember(Member member) { + Member savedMember = memberRepository.save(member); + if (savedMember == null) { + log.error("[CreateMemberWithOAuthService] member could not be saved"); + throw MemberErrorCode.MEMBER_SAVE_FAILED.toException(); + } + return savedMember; + } +} diff --git a/src/main/java/spring/backend/member/domain/service/EditMemberProfileService.java b/src/main/java/spring/backend/member/domain/service/EditMemberProfileService.java new file mode 100644 index 000000000..9e37b7298 --- /dev/null +++ b/src/main/java/spring/backend/member/domain/service/EditMemberProfileService.java @@ -0,0 +1,23 @@ +package spring.backend.member.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.presentation.dto.request.EditMemberProfileRequest; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class EditMemberProfileService { + + private final MemberRepository memberRepository; + + @Transactional + public void edit(Member member, EditMemberProfileRequest editMemberProfileRequest) { + member.editMemberProfile(editMemberProfileRequest.nickname(), editMemberProfileRequest.profileImage()); + memberRepository.save(member); + } +} diff --git a/src/main/java/spring/backend/member/domain/service/ValidateNicknameService.java b/src/main/java/spring/backend/member/domain/service/ValidateNicknameService.java new file mode 100644 index 000000000..9e94646ea --- /dev/null +++ b/src/main/java/spring/backend/member/domain/service/ValidateNicknameService.java @@ -0,0 +1,35 @@ +package spring.backend.member.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.domain.value.Role; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class ValidateNicknameService { + + private final MemberRepository memberRepository; + + public boolean validateNickname(String nickname) { + if (nickname == null || nickname.isBlank()) { + log.error("[ValidateNicknameService] Nickname is empty"); + return false; + } + if (nickname.length() > 6) { + log.error("[ValidateNicknameService] Nickname is smaller than 6 characters"); + return false; + } + if (!nickname.matches("^[a-zA-Z0-9가-힣ㄱ-ㅎ]{1,6}$")) { + log.error("[ValidateNicknameService] Nickname is invalid"); + return false; + } + if (memberRepository.existsByNicknameAndRole(nickname, Role.MEMBER)) { + log.error("[ValidateNicknameService] Nickname is already in use"); + return false; + } + return true; + } +} diff --git a/src/main/java/spring/backend/member/domain/value/Gender.java b/src/main/java/spring/backend/member/domain/value/Gender.java new file mode 100644 index 000000000..7154d7c7b --- /dev/null +++ b/src/main/java/spring/backend/member/domain/value/Gender.java @@ -0,0 +1,11 @@ +package spring.backend.member.domain.value; + +import lombok.Getter; + +@Getter +public enum Gender { + + MALE, + FEMALE, + NONE +} diff --git a/src/main/java/spring/backend/member/domain/value/Provider.java b/src/main/java/spring/backend/member/domain/value/Provider.java new file mode 100644 index 000000000..7c5e8cdce --- /dev/null +++ b/src/main/java/spring/backend/member/domain/value/Provider.java @@ -0,0 +1,8 @@ +package spring.backend.member.domain.value; + +import lombok.Getter; + +@Getter +public enum Provider { + GOOGLE, NAVER, KAKAO +} diff --git a/src/main/java/spring/backend/member/domain/value/Role.java b/src/main/java/spring/backend/member/domain/value/Role.java new file mode 100644 index 000000000..c9b892204 --- /dev/null +++ b/src/main/java/spring/backend/member/domain/value/Role.java @@ -0,0 +1,8 @@ +package spring.backend.member.domain.value; + +import lombok.Getter; + +@Getter +public enum Role { + MEMBER, GUEST +} diff --git a/src/main/java/spring/backend/member/dto/response/HomeMemberInfoResponse.java b/src/main/java/spring/backend/member/dto/response/HomeMemberInfoResponse.java new file mode 100644 index 000000000..8f5ddfd6c --- /dev/null +++ b/src/main/java/spring/backend/member/dto/response/HomeMemberInfoResponse.java @@ -0,0 +1,22 @@ +package spring.backend.member.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.member.domain.entity.Member; + +import java.util.UUID; + +public record HomeMemberInfoResponse( + + @Schema(description = "멤버 ID", example = "4d45be4b-1cb0-4760-b826-7afc505783cd") + UUID id, + + @Schema(description = "닉네임", example = "조각조각") + String nickname, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/image.jpg") + String profileImage +) { + public static HomeMemberInfoResponse from(Member member) { + return new HomeMemberInfoResponse(member.getId(), member.getNickname(), member.getProfileImage()); + } +} diff --git a/src/main/java/spring/backend/member/exception/MemberErrorCode.java b/src/main/java/spring/backend/member/exception/MemberErrorCode.java new file mode 100644 index 000000000..9e05748f3 --- /dev/null +++ b/src/main/java/spring/backend/member/exception/MemberErrorCode.java @@ -0,0 +1,34 @@ +package spring.backend.member.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorCode implements BaseErrorCode { + + NOT_EXIST_CONDITION(HttpStatus.BAD_REQUEST, "요청 조건이 존재하지 않습니다."), + ALREADY_REGISTERED_WITH_DIFFERENT_OAUTH2(HttpStatus.BAD_REQUEST, "이미 다른 소셜 로그인으로 가입된 계정입니다."), + MEMBER_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "사용자 정보를 저장하는데 실패하였습니다."), + NOT_EXIST_MEMBER(HttpStatus.NOT_FOUND, "사용자가 존재하지 않습니다."), + ALREADY_REGISTERED_MEMBER(HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), + NOT_EXIST_NICKNAME(HttpStatus.BAD_REQUEST, "닉네임은 필수 입력값입니다."), + INVALID_NICKNAME_LENGTH(HttpStatus.BAD_REQUEST, "닉네임은 1자에서 6자 사이여야 합니다."), + INVALID_NICKNAME_FORMAT(HttpStatus.BAD_REQUEST, "닉네임은 한글, 영문, 숫자 조합이어야 합니다."), + ALREADY_REGISTERED_NICKNAME(HttpStatus.BAD_REQUEST, "닉네임이 이미 사용 중입니다."), + NOT_AUTHORIZED_MEMBER(HttpStatus.FORBIDDEN, "회원가입을 완료한 사용자가 아닙니다."), + ALREADY_ENABLE_EMAIL_NOTIFICATION(HttpStatus.BAD_REQUEST, "이메일 알림이 이미 활성화되어 있습니다."), + ALREADY_DISABLE_EMAIL_NOTIFICATION(HttpStatus.BAD_REQUEST, "이메일 알림이 이미 비활성화되어 있습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/member/infrastructure/mapper/MemberMapper.java b/src/main/java/spring/backend/member/infrastructure/mapper/MemberMapper.java new file mode 100644 index 000000000..1a9ba2817 --- /dev/null +++ b/src/main/java/spring/backend/member/infrastructure/mapper/MemberMapper.java @@ -0,0 +1,17 @@ +package spring.backend.member.infrastructure.mapper; + +import org.springframework.stereotype.Component; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.infrastructure.persistence.jpa.entity.MemberJpaEntity; + +@Component +public class MemberMapper { + + public Member toDomainEntity(MemberJpaEntity member) { + return Member.toDomainEntity(member); + } + + public MemberJpaEntity toJpaEntity(Member member) { + return MemberJpaEntity.toJpaEntity(member); + } +} diff --git a/src/main/java/spring/backend/member/infrastructure/persistence/jpa/adapter/MemberRepositoryImpl.java b/src/main/java/spring/backend/member/infrastructure/persistence/jpa/adapter/MemberRepositoryImpl.java new file mode 100644 index 000000000..449aac282 --- /dev/null +++ b/src/main/java/spring/backend/member/infrastructure/persistence/jpa/adapter/MemberRepositoryImpl.java @@ -0,0 +1,77 @@ +package spring.backend.member.infrastructure.persistence.jpa.adapter; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Repository; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.domain.value.Role; +import spring.backend.member.exception.MemberErrorCode; +import spring.backend.member.infrastructure.mapper.MemberMapper; +import spring.backend.member.infrastructure.persistence.jpa.entity.MemberJpaEntity; +import spring.backend.member.infrastructure.persistence.jpa.repository.MemberJpaRepository; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +@Log4j2 +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberMapper memberMapper; + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member findById(UUID id) { + MemberJpaEntity memberJpaEntity = memberJpaRepository.findById(id).orElse(null); + if (memberJpaEntity == null) { + return null; + } + return memberMapper.toDomainEntity(memberJpaEntity); + } + + @Override + public Member save(Member member) { + MemberJpaEntity memberJpaEntity = memberMapper.toJpaEntity(member); + if (memberJpaEntity == null) { + throw MemberErrorCode.MEMBER_SAVE_FAILED.toException(); + } + memberJpaRepository.save(memberJpaEntity); + return memberMapper.toDomainEntity(memberJpaEntity); + } + + @Override + public Member findByEmail(String email) { + MemberJpaEntity memberJpaEntity = memberJpaRepository.findByEmail(email); + if (memberJpaEntity == null) { + return null; + } + return memberMapper.toDomainEntity(memberJpaEntity); + } + + @Override + public List findAllByEmail(String email) { + List memberJpaEntities = memberJpaRepository.findAllByEmail(email); + if (memberJpaEntities == null || memberJpaEntities.isEmpty()) { + return null; + } + return memberJpaEntities.stream().map(memberMapper::toDomainEntity).collect(Collectors.toList()); + } + + @Override + public boolean existsByNicknameAndRole(String nickname, Role role) { + return memberJpaRepository.existsByNicknameAndRole(nickname, role); + } + + @Override + public List findMembersForQuickStartsInTimeRange(LocalTime lowerBound, LocalTime upperBound) { + List memberJpaEntities = memberJpaRepository.findMembersForQuickStartsInTimeRange(lowerBound, upperBound); + if (memberJpaEntities == null || memberJpaEntities.isEmpty()) { + return null; + } + return memberJpaEntities.stream().map(memberMapper::toDomainEntity).collect(Collectors.toList()); + } +} diff --git a/src/main/java/spring/backend/member/infrastructure/persistence/jpa/entity/MemberJpaEntity.java b/src/main/java/spring/backend/member/infrastructure/persistence/jpa/entity/MemberJpaEntity.java new file mode 100644 index 000000000..6bb536107 --- /dev/null +++ b/src/main/java/spring/backend/member/infrastructure/persistence/jpa/entity/MemberJpaEntity.java @@ -0,0 +1,61 @@ +package spring.backend.member.infrastructure.persistence.jpa.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import spring.backend.core.infrastructure.jpa.shared.BaseEntity; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.value.Gender; +import spring.backend.member.domain.value.Provider; +import spring.backend.member.domain.value.Role; + +import java.util.Optional; + +@Entity +@Table(name = "member") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberJpaEntity extends BaseEntity { + + @Enumerated(EnumType.STRING) + private Provider provider; + + @Enumerated(EnumType.STRING) + private Role role; + + private String email; + + private String nickname; + + private int birthYear; + + @Enumerated(EnumType.STRING) + private Gender gender; + + private String profileImage; + + private boolean emailNotification; + + public static MemberJpaEntity toJpaEntity(Member member) { + return MemberJpaEntity.builder() + .id(member.getId()) + .provider(member.getProvider()) + .role(member.getRole()) + .email(member.getEmail()) + .nickname(member.getNickname()) + .birthYear(member.getBirthYear()) + .gender(member.getGender()) + .profileImage(member.getProfileImage()) + .emailNotification(member.isEmailNotification()) + .createdAt(member.getCreatedAt()) + .updatedAt(member.getUpdatedAt()) + .deleted(Optional.ofNullable(member.getDeleted()).orElse(false)) + .build(); + } +} diff --git a/src/main/java/spring/backend/member/infrastructure/persistence/jpa/repository/MemberJpaRepository.java b/src/main/java/spring/backend/member/infrastructure/persistence/jpa/repository/MemberJpaRepository.java new file mode 100644 index 000000000..809dae7b3 --- /dev/null +++ b/src/main/java/spring/backend/member/infrastructure/persistence/jpa/repository/MemberJpaRepository.java @@ -0,0 +1,28 @@ +package spring.backend.member.infrastructure.persistence.jpa.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import spring.backend.member.domain.value.Role; +import spring.backend.member.infrastructure.persistence.jpa.entity.MemberJpaEntity; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +public interface MemberJpaRepository extends JpaRepository { + + MemberJpaEntity findByEmail(String email); + + List findAllByEmail(String email); + + boolean existsByNicknameAndRole(String nickname, Role role); + + @Query(""" + select distinct m + from MemberJpaEntity m + join QuickStartJpaEntity q on q.memberId = m.id + where q.startTime between :lowerBound and :upperBound + and m.emailNotification = true + """) + List findMembersForQuickStartsInTimeRange(LocalTime lowerBound, LocalTime upperBound); +} diff --git a/src/main/java/spring/backend/member/presentation/ChangeEmailNotificationController.java b/src/main/java/spring/backend/member/presentation/ChangeEmailNotificationController.java new file mode 100644 index 000000000..7437e0ac1 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/ChangeEmailNotificationController.java @@ -0,0 +1,25 @@ +package spring.backend.member.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.service.ChangeEmailNotificationService; +import spring.backend.member.presentation.swagger.ChangeEmailNotificationSwagger; + +@RestController +@RequiredArgsConstructor +public class ChangeEmailNotificationController implements ChangeEmailNotificationSwagger { + + private final ChangeEmailNotificationService changeEmailNotificationService; + + @Authorization + @PatchMapping("/v1/members/email-notification") + public ResponseEntity changeEmailNotification(@AuthorizedMember Member member) { + changeEmailNotificationService.changeEmailNotification(member); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/spring/backend/member/presentation/EditMemberProfileController.java b/src/main/java/spring/backend/member/presentation/EditMemberProfileController.java new file mode 100644 index 000000000..fd7f9ce43 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/EditMemberProfileController.java @@ -0,0 +1,27 @@ +package spring.backend.member.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import spring.backend.member.presentation.dto.request.EditMemberProfileRequest; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.member.domain.service.EditMemberProfileService; +import spring.backend.member.presentation.swagger.EditMemberProfileSwagger; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class EditMemberProfileController implements EditMemberProfileSwagger { + + private final EditMemberProfileService editMemberProfileService; + + @Override + @PatchMapping("/v1/member/profile") + @Authorization + @ResponseStatus(HttpStatus.OK) + public void editMemberProfile(@AuthorizedMember Member member, @Valid @RequestBody EditMemberProfileRequest editMemberProfileRequest) { + editMemberProfileService.edit(member, editMemberProfileRequest); + } +} diff --git a/src/main/java/spring/backend/member/presentation/ReadMemberHomeController.java b/src/main/java/spring/backend/member/presentation/ReadMemberHomeController.java new file mode 100644 index 000000000..c9aaed924 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/ReadMemberHomeController.java @@ -0,0 +1,26 @@ +package spring.backend.member.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.application.ReadMemberHomeService; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.presentation.dto.response.HomeMainResponse; +import spring.backend.member.presentation.swagger.ReadMemberHomeSwagger; + +@RestController +@RequiredArgsConstructor +public class ReadMemberHomeController implements ReadMemberHomeSwagger { + + private final ReadMemberHomeService readMemberHomeService; + + @Authorization + @GetMapping("/v1/home") + public ResponseEntity> readMemberHome(@AuthorizedMember Member member) { + return ResponseEntity.ok(new RestResponse<>(readMemberHomeService.readMemberHome(member))); + } +} diff --git a/src/main/java/spring/backend/member/presentation/ReadMemberProfileController.java b/src/main/java/spring/backend/member/presentation/ReadMemberProfileController.java new file mode 100644 index 000000000..a281c4ffd --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/ReadMemberProfileController.java @@ -0,0 +1,23 @@ +package spring.backend.member.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.presentation.dto.response.MemberProfileResponse; +import spring.backend.member.presentation.swagger.ReadMemberProfileSwagger; + +@RestController +@RequestMapping +public class ReadMemberProfileController implements ReadMemberProfileSwagger { + + @Authorization + @GetMapping("/v1/profile") + public ResponseEntity> readMemberProfile(@AuthorizedMember Member member) { + return ResponseEntity.ok(new RestResponse<>(MemberProfileResponse.from(member))); + } +} diff --git a/src/main/java/spring/backend/member/presentation/ValidateNicknameController.java b/src/main/java/spring/backend/member/presentation/ValidateNicknameController.java new file mode 100644 index 000000000..edf752591 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/ValidateNicknameController.java @@ -0,0 +1,22 @@ +package spring.backend.member.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.service.ValidateNicknameService; +import spring.backend.member.presentation.swagger.ValidateNicknameSwagger; + +@RestController +@RequiredArgsConstructor +public class ValidateNicknameController implements ValidateNicknameSwagger { + + private final ValidateNicknameService validateNicknameService; + + @GetMapping("/v1/members/check-nickname") + public ResponseEntity> validateNickname(String nickname) { + boolean isValidNickname = validateNicknameService.validateNickname(nickname); + return ResponseEntity.ok(new RestResponse<>(isValidNickname)); + } +} diff --git a/src/main/java/spring/backend/member/presentation/dto/request/CreateMemberWithOAuthRequest.java b/src/main/java/spring/backend/member/presentation/dto/request/CreateMemberWithOAuthRequest.java new file mode 100644 index 000000000..6d3fc78d2 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/dto/request/CreateMemberWithOAuthRequest.java @@ -0,0 +1,16 @@ +package spring.backend.member.presentation.dto.request; + +import lombok.Builder; +import lombok.Getter; +import spring.backend.member.domain.value.Provider; + +@Getter +@Builder +public class CreateMemberWithOAuthRequest { + + private final Provider provider; + + private final String email; + + private final String nickname; +} diff --git a/src/main/java/spring/backend/member/presentation/dto/request/EditMemberProfileRequest.java b/src/main/java/spring/backend/member/presentation/dto/request/EditMemberProfileRequest.java new file mode 100644 index 000000000..e5b6313d3 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/dto/request/EditMemberProfileRequest.java @@ -0,0 +1,17 @@ +package spring.backend.member.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record EditMemberProfileRequest( + @Pattern(regexp = "^[a-zA-Z0-9가-힣]{1,6}$", message = "닉네임은 한글, 영문, 숫자 조합 6자 이내로 입력해주세요.") + @NotBlank(message = "닉네임을 입력해주세요.") + @Schema(description = "닉네임", example = "조각조각") + String nickname, + + @NotBlank(message = "프로필 이미지를 선택해주세요.") + @Schema(description = "프로필 이미지", example = "http://test.jpg") + String profileImage +) { +} diff --git a/src/main/java/spring/backend/member/presentation/dto/response/HomeMainResponse.java b/src/main/java/spring/backend/member/presentation/dto/response/HomeMainResponse.java new file mode 100644 index 000000000..cea691104 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/dto/response/HomeMainResponse.java @@ -0,0 +1,27 @@ +package spring.backend.member.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.dto.response.HomeActivityInfoResponse; +import spring.backend.member.dto.response.HomeMemberInfoResponse; +import spring.backend.quickstart.dto.response.QuickStartResponse; + +import java.util.List; + +public record HomeMainResponse( + + @Schema(description = "회원 정보") + HomeMemberInfoResponse member, + + @Schema(description = "빠른 시작") + QuickStartResponse quickStart, + + @Schema(description = "총 모은 시간", example = "120") + int totalSavedTime, + + @Schema(description = "활동 목록") + List activities +) { + public static HomeMainResponse of(HomeMemberInfoResponse member, QuickStartResponse quickStart, int totalSavedTime, List activities) { + return new HomeMainResponse(member, quickStart, totalSavedTime, activities); + } +} diff --git a/src/main/java/spring/backend/member/presentation/dto/response/MemberProfileResponse.java b/src/main/java/spring/backend/member/presentation/dto/response/MemberProfileResponse.java new file mode 100644 index 000000000..790d36054 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/dto/response/MemberProfileResponse.java @@ -0,0 +1,17 @@ +package spring.backend.member.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.member.domain.entity.Member; + +public record MemberProfileResponse( + + @Schema(description = "이메일", example = "jogakjogak@gmail.com") + String email, + + @Schema(description = "이메일 알림 설정 여부", example = "true") + boolean isEmailNotificationEnabled +) { + public static MemberProfileResponse from(Member member) { + return new MemberProfileResponse(member.getEmail(), member.isEmailNotification()); + } +} diff --git a/src/main/java/spring/backend/member/presentation/swagger/ChangeEmailNotificationSwagger.java b/src/main/java/spring/backend/member/presentation/swagger/ChangeEmailNotificationSwagger.java new file mode 100644 index 000000000..34ea2a850 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/swagger/ChangeEmailNotificationSwagger.java @@ -0,0 +1,22 @@ +package spring.backend.member.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.exception.MemberErrorCode; + +@Tag(name = "Member", description = "멤버") +public interface ChangeEmailNotificationSwagger { + + @Operation( + summary = "이메일 알림 설정 API", + description = "사용자의 이메일 알림 설정을 변경합니다. \n\n 기본적으로 이메일 알림은 활성화되어 있으며, 요청을 통해 활성화 또는 비활성화할 수 있습니다.", + operationId = "/v1/members/email-notification" + ) + @ApiErrorCode({GlobalErrorCode.class, MemberErrorCode.class}) + ResponseEntity changeEmailNotification(@Parameter(hidden = true) Member member); +} diff --git a/src/main/java/spring/backend/member/presentation/swagger/EditMemberProfileSwagger.java b/src/main/java/spring/backend/member/presentation/swagger/EditMemberProfileSwagger.java new file mode 100644 index 000000000..f883c9e3b --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/swagger/EditMemberProfileSwagger.java @@ -0,0 +1,22 @@ +package spring.backend.member.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.presentation.dto.request.EditMemberProfileRequest; +import spring.backend.member.exception.MemberErrorCode; + +@Tag(name = "Member", description = "멤버") +public interface EditMemberProfileSwagger { + + @Operation( + summary = "멤버 프로필 수정 API", + description = "사용자의 프로필을 수정합니다.", + operationId = "/v1/member/profile" + ) + @ApiErrorCode({GlobalErrorCode.class, MemberErrorCode.class}) + void editMemberProfile(@Parameter(hidden = true) Member member, EditMemberProfileRequest request); +} diff --git a/src/main/java/spring/backend/member/presentation/swagger/ReadMemberHomeSwagger.java b/src/main/java/spring/backend/member/presentation/swagger/ReadMemberHomeSwagger.java new file mode 100644 index 000000000..0c14e1278 --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/swagger/ReadMemberHomeSwagger.java @@ -0,0 +1,24 @@ +package spring.backend.member.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.presentation.dto.response.HomeMainResponse; +import spring.backend.member.exception.MemberErrorCode; + +@Tag(name = "Member", description = "멤버") +public interface ReadMemberHomeSwagger { + + @Operation( + summary = "홈 메인페이지 조회 API", + description = "사용자의 가장 근접한 빠른시작과 당일 모은 활동내역을 보여줍니다.", + operationId = "/v1/home" + ) + @ApiErrorCode({GlobalErrorCode.class, MemberErrorCode.class}) + ResponseEntity> readMemberHome(@Parameter(hidden = true) Member member); +} diff --git a/src/main/java/spring/backend/member/presentation/swagger/ReadMemberProfileSwagger.java b/src/main/java/spring/backend/member/presentation/swagger/ReadMemberProfileSwagger.java new file mode 100644 index 000000000..de0bcc0ed --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/swagger/ReadMemberProfileSwagger.java @@ -0,0 +1,23 @@ +package spring.backend.member.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.presentation.dto.response.MemberProfileResponse; + +@Tag(name = "Member", description = "멤버") +public interface ReadMemberProfileSwagger { + + @Operation( + summary = "프로필 조회 API", + description = "사용자의 프로필을 조회합니다.", + operationId = "/v1/profile" + ) + @ApiErrorCode({GlobalErrorCode.class}) + ResponseEntity> readMemberProfile(@Parameter(hidden = true) Member member); +} diff --git a/src/main/java/spring/backend/member/presentation/swagger/ValidateNicknameSwagger.java b/src/main/java/spring/backend/member/presentation/swagger/ValidateNicknameSwagger.java new file mode 100644 index 000000000..36af74fad --- /dev/null +++ b/src/main/java/spring/backend/member/presentation/swagger/ValidateNicknameSwagger.java @@ -0,0 +1,22 @@ +package spring.backend.member.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.exception.MemberErrorCode; + +@Tag(name = "Member", description = "멤버") +public interface ValidateNicknameSwagger { + + @Operation( + summary = "닉네임 중복 검증 API", + description = "닉네임이 조건에 충족하지 않거나 중복일 경우 false를 반환합니다.", + operationId = "/v1/members/check-nickname" + ) + @ApiErrorCode({GlobalErrorCode.class, MemberErrorCode.class}) + ResponseEntity> validateNickname(@Schema(description = "요청 닉네임", example = "조각조각") String nickname); +} diff --git a/src/main/java/spring/backend/quickstart/application/ReadQuickStartsService.java b/src/main/java/spring/backend/quickstart/application/ReadQuickStartsService.java new file mode 100644 index 000000000..48c58a0c5 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/application/ReadQuickStartsService.java @@ -0,0 +1,27 @@ +package spring.backend.quickstart.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.quickstart.dto.response.QuickStartResponse; +import spring.backend.quickstart.presentation.dto.response.QuickStartsResponse; +import spring.backend.quickstart.query.dao.QuickStartDao; +import spring.backend.member.domain.entity.Member; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional(readOnly = true) +public class ReadQuickStartsService { + + private final QuickStartDao quickStartDao; + + public QuickStartsResponse readQuickStarts(Member member) { + List quickStartResponses = quickStartDao.findByMemberId(member.getId(), Sort.by("createdAt").descending()); + return new QuickStartsResponse(quickStartResponses); + } +} diff --git a/src/main/java/spring/backend/quickstart/application/SendQuickStartEmailsScheduler.java b/src/main/java/spring/backend/quickstart/application/SendQuickStartEmailsScheduler.java new file mode 100644 index 000000000..f33f2f0f4 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/application/SendQuickStartEmailsScheduler.java @@ -0,0 +1,120 @@ +package spring.backend.quickstart.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import spring.backend.quickstart.domain.entity.QuickStart; +import spring.backend.quickstart.domain.repository.QuickStartRepository; +import spring.backend.core.util.email.EmailUtil; +import spring.backend.core.util.email.dto.request.SendEmailRequest; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class SendQuickStartEmailsScheduler { + + private final QuickStartRepository quickStartRepository; + + private final MemberRepository memberRepository; + + private final EmailUtil emailUtil; + + private final TemplateEngine templateEngine; + + private static final int TIME_INTERVAL_MINUTES = 15; + + @Scheduled(cron = "0 0/15 * * * ?") + public void sendQuickStartEmails() { + List receivers = collectEmailReceiversWithinTimeRange(); + + if (!receivers.isEmpty()) { + sendEmails(receivers); + } else { + log.warn("[SendQuickStartEmailsScheduler] No valid receiver found in the time range."); + } + } + + private void sendEmails(List receivers) { + List receiverIds = receivers.stream() + .map(Member::getId) + .collect(Collectors.toList()); + + List earliestQuickStartsForMembers = collectEarliestQuickStartsForMembers(receiverIds); + + receivers.forEach(receiver -> { + QuickStart earliestQuickStart = findEarliestQuickStartForReceiver(receiver, earliestQuickStartsForMembers); + if (earliestQuickStart != null) { + sendEmail(receiver, earliestQuickStart); + } else { + log.warn("[SendQuickStartEmailsScheduler] No quick start found for receiver {}", receiver); + } + }); + } + + private List collectEmailReceiversWithinTimeRange() { + LocalTime lowerBound = getLowerBound(); + LocalTime upperBound = getUpperBound(lowerBound); + + log.info("[SendQuickStartEmailsScheduler] Searching for receivers between {} and {}", lowerBound, upperBound); + + return memberRepository.findMembersForQuickStartsInTimeRange(lowerBound, upperBound); + } + + private List collectEarliestQuickStartsForMembers(List receiverIds) { + LocalTime lowerBound = getLowerBound(); + LocalTime upperBound = getUpperBound(lowerBound); + + return quickStartRepository.findEarliestQuickStartsForMembers(receiverIds, lowerBound, upperBound); + } + + private QuickStart findEarliestQuickStartForReceiver(Member receiver, List earliestQuickStartsForMembers) { + return earliestQuickStartsForMembers.stream() + .filter(quickStart -> receiver.getId().equals(quickStart.getMemberId())) + .findFirst() + .orElse(null); + } + + private void sendEmail(Member receiver, QuickStart earliestQuickStart) { + String title = generateEmailTitle(receiver, earliestQuickStart); + SendEmailRequest request = SendEmailRequest.builder() + .title(title) + .content(generateEmailContent()) + .receiver(receiver.getEmail()) + .build(); + + try { + emailUtil.send(request); + log.info("[SendQuickStartEmailsScheduler] Successfully sent email to {}", receiver); + } catch (Exception e) { + log.error("[SendQuickStartEmailsScheduler] Failed to send email to {}", receiver, e); + } + } + + private String generateEmailTitle(Member receiver, QuickStart earliestQuickStart) { + return "%s 님, %s의 시간 조각을 모으러 갈 시간이에요 ⏰".formatted(receiver.getNickname(), earliestQuickStart.getName()); + } + + private String generateEmailContent() { + Context context = new Context(); + return templateEngine.process("mail", context); + } + + private LocalTime getLowerBound() { + LocalTime now = LocalTime.now(); + return now.plusMinutes(1).withSecond(0).withNano(0); + } + + private LocalTime getUpperBound(LocalTime lowerBound) { + return lowerBound.plusMinutes(TIME_INTERVAL_MINUTES - 1); + } +} diff --git a/src/main/java/spring/backend/quickstart/domain/entity/QuickStart.java b/src/main/java/spring/backend/quickstart/domain/entity/QuickStart.java new file mode 100644 index 000000000..74dff341e --- /dev/null +++ b/src/main/java/spring/backend/quickstart/domain/entity/QuickStart.java @@ -0,0 +1,49 @@ +package spring.backend.quickstart.domain.entity; + +import lombok.Builder; +import lombok.Getter; +import spring.backend.activity.domain.value.Type; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.UUID; + +@Getter +@Builder +public class QuickStart { + + private Long id; + + private UUID memberId; + + private String name; + + private LocalTime startTime; + + private Integer spareTime; + + private Type type; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private Boolean deleted; + + public static QuickStart create(UUID memberId, String name, LocalTime startTime, Integer spareTime, Type type) { + return QuickStart.builder() + .memberId(memberId) + .name(name) + .startTime(startTime) + .spareTime(spareTime) + .type(type) + .build(); + } + + public void update(String name, LocalTime startTime, Integer spareTime, Type type) { + this.name = name; + this.startTime = startTime; + this.spareTime = spareTime; + this.type = type; + } +} diff --git a/src/main/java/spring/backend/quickstart/domain/repository/QuickStartRepository.java b/src/main/java/spring/backend/quickstart/domain/repository/QuickStartRepository.java new file mode 100644 index 000000000..6556d8c32 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/domain/repository/QuickStartRepository.java @@ -0,0 +1,14 @@ +package spring.backend.quickstart.domain.repository; + +import spring.backend.quickstart.domain.entity.QuickStart; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +public interface QuickStartRepository { + + QuickStart findById(Long id); + QuickStart save(QuickStart quickStart); + List findEarliestQuickStartsForMembers(List memberIds, LocalTime lowerBound, LocalTime upperBound); +} diff --git a/src/main/java/spring/backend/quickstart/domain/service/CreateQuickStartService.java b/src/main/java/spring/backend/quickstart/domain/service/CreateQuickStartService.java new file mode 100644 index 000000000..3fe25efc3 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/domain/service/CreateQuickStartService.java @@ -0,0 +1,48 @@ +package spring.backend.quickstart.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.quickstart.domain.entity.QuickStart; +import spring.backend.quickstart.domain.repository.QuickStartRepository; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.core.util.TimeUtil; +import spring.backend.member.domain.entity.Member; + +import java.time.LocalTime; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class CreateQuickStartService { + + private final QuickStartRepository quickStartRepository; + + public Long createQuickStart(Member member, QuickStartRequest request) { + validateRequest(request); + + LocalTime startTime = TimeUtil.toLocalTime(request.meridiem(), request.hour(), request.minute()); + validateStartTime(startTime); + + QuickStart quickStart = QuickStart.create(member.getId(), request.name(), startTime, request.spareTime(), request.type()); + QuickStart savedQuickStart = quickStartRepository.save(quickStart); + return savedQuickStart.getId(); + } + + private void validateRequest(QuickStartRequest request) { + if (request == null) { + log.error("[CreateQuickStartService] Invalid request."); + throw QuickStartErrorCode.NOT_EXIST_QUICK_START_CONDITION.toException(); + } + } + + private void validateStartTime(LocalTime time) { + if (time == null) { + log.error("[CreateQuickStartService] Invalid start time."); + throw QuickStartErrorCode.START_TIME_CONVERSION_FAILED.toException(); + } + } +} diff --git a/src/main/java/spring/backend/quickstart/domain/service/UpdateQuickStartService.java b/src/main/java/spring/backend/quickstart/domain/service/UpdateQuickStartService.java new file mode 100644 index 000000000..499169878 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/domain/service/UpdateQuickStartService.java @@ -0,0 +1,69 @@ +package spring.backend.quickstart.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spring.backend.quickstart.domain.entity.QuickStart; +import spring.backend.quickstart.domain.repository.QuickStartRepository; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.core.util.TimeUtil; +import spring.backend.member.domain.entity.Member; + +import java.time.LocalTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class UpdateQuickStartService { + + private final QuickStartRepository quickStartRepository; + + public void updateQuickStart(Member member, QuickStartRequest request, Long quickStartId) { + QuickStart quickStart = quickStartRepository.findById(quickStartId); + validateUpdateRequest(member, request, quickStart); + + LocalTime startTime = TimeUtil.toLocalTime(request.meridiem(), request.hour(), request.minute()); + validateStartTime(startTime); + + quickStart.update(request.name(), startTime, request.spareTime(), request.type()); + quickStartRepository.save(quickStart); + } + + private void validateUpdateRequest(Member member, QuickStartRequest request, QuickStart quickStart) { + validateQuickStartExistence(quickStart); + validateRequest(request); + validateMemberId(member.getId(), quickStart.getMemberId()); + } + + private void validateQuickStartExistence(QuickStart quickStart) { + if (quickStart == null) { + log.error("[validateQuickStartExistence] QuickStart does not exist."); + throw QuickStartErrorCode.NOT_EXIST_QUICK_START.toException(); + } + } + + private void validateRequest(QuickStartRequest request) { + if (request == null) { + log.error("[validateRequest] Request is null."); + throw QuickStartErrorCode.NOT_EXIST_QUICK_START_CONDITION.toException(); + } + } + + private void validateMemberId(UUID memberId, UUID quickStartMemberId) { + if (!memberId.equals(quickStartMemberId)) { + log.error("[validateMemberId] Member id mismatch"); + throw QuickStartErrorCode.MEMBER_ID_MISMATCH.toException(); + } + } + + private void validateStartTime(LocalTime time) { + if (time == null) { + log.error("[UpdateQuickStartService] Invalid start time."); + throw QuickStartErrorCode.START_TIME_CONVERSION_FAILED.toException(); + } + } +} diff --git a/src/main/java/spring/backend/quickstart/dto/response/QuickStartResponse.java b/src/main/java/spring/backend/quickstart/dto/response/QuickStartResponse.java new file mode 100644 index 000000000..6a7578bb3 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/dto/response/QuickStartResponse.java @@ -0,0 +1,38 @@ +package spring.backend.quickstart.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.domain.value.Type; +import spring.backend.core.util.TimeUtil; + +import java.time.LocalTime; + +public record QuickStartResponse( + + @Schema(description = "빠른 시작 ID", example = "1") + Long id, + + @Schema(description = "빠른 시작 이름", example = "등교") + String name, + + @Schema(description = "시작 시간", example = "12:30") + LocalTime startTime, + + @Schema(description = "자투리 시간", example = "300") + Integer spareTime, + + @Schema(description = "활동 유형 (ONLINE, OFFLINE, ONLINE_AND_OFFLINE)", example = "ONLINE") + Type type, + + @Schema(description = "시작 시간의 시", example = "12") + Integer hour, + + @Schema(description = "시작 시간의 분", example = "30") + Integer minute, + + @Schema(description = "오전/오후 표시", example = "오후") + String meridiem +) { + public QuickStartResponse(Long id, String name, LocalTime startTime, Integer spareTime, Type type) { + this(id, name, startTime, spareTime, type, TimeUtil.toHour(startTime), TimeUtil.toMinute(startTime), TimeUtil.toMeridiem(startTime)); + } +} diff --git a/src/main/java/spring/backend/quickstart/exception/QuickStartErrorCode.java b/src/main/java/spring/backend/quickstart/exception/QuickStartErrorCode.java new file mode 100644 index 000000000..a720aeb88 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/exception/QuickStartErrorCode.java @@ -0,0 +1,27 @@ +package spring.backend.quickstart.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum QuickStartErrorCode implements BaseErrorCode { + + QUICK_START_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "빠른 시작 정보를 저장하는데 실패하였습니다."), + NOT_EXIST_QUICK_START_CONDITION(HttpStatus.BAD_REQUEST, "빠른 시작 요청 조건이 유효하지 않습니다."), + NOT_EXIST_QUICK_START(HttpStatus.BAD_REQUEST, "빠른 시작이 존재하지 않습니다."), + MEMBER_ID_MISMATCH(HttpStatus.FORBIDDEN, "빠른 시작과 멤버 ID가 일치하지 않습니다."), + START_TIME_CONVERSION_FAILED(HttpStatus.BAD_REQUEST, "빠른 시작 시작 시간 변환에 실패하였습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/quickstart/infrastructure/batch/job/SendQuickStartEmailsJob.java b/src/main/java/spring/backend/quickstart/infrastructure/batch/job/SendQuickStartEmailsJob.java new file mode 100644 index 000000000..8673773ce --- /dev/null +++ b/src/main/java/spring/backend/quickstart/infrastructure/batch/job/SendQuickStartEmailsJob.java @@ -0,0 +1,158 @@ +package spring.backend.quickstart.infrastructure.batch.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import spring.backend.quickstart.infrastructure.persistence.jpa.entity.QuickStartJpaEntity; +import spring.backend.quickstart.infrastructure.persistence.jpa.repository.QuickStartJpaRepository; +import spring.backend.core.util.email.EmailUtil; +import spring.backend.core.util.email.dto.request.SendEmailRequest; +import spring.backend.member.infrastructure.persistence.jpa.entity.MemberJpaEntity; +import spring.backend.member.infrastructure.persistence.jpa.repository.MemberJpaRepository; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Configuration +@RequiredArgsConstructor +@Log4j2 +public class SendQuickStartEmailsJob { + + private final JobRepository jobRepository; + + private final JobLauncher jobLauncher; + + private final PlatformTransactionManager platformTransactionManager; + + private final QuickStartJpaRepository quickStartJpaRepository; + + private final MemberJpaRepository memberJpaRepository; + + private final EmailUtil emailUtil; + + private final TemplateEngine templateEngine; + + private static final int TIME_INTERVAL_MINUTES = 15; + + // @Scheduled(cron = "0 0/15 * * * ?") + public void sendQuickStartEmailsJobScheduler() { + try { + JobParameters jobParameters = new JobParametersBuilder() + .addDate("time", new Date()) + .toJobParameters(); + jobLauncher.run(sendQuickStartEmailsJob(), jobParameters); + } catch (Exception e) { + log.error("[SendQuickStartEmailsJob] Scheduler encountered an error at {}", LocalDateTime.now(), e); + } + } + + public Job sendQuickStartEmailsJob() { + final String JOB_NAME = "sendQuickStartEmailsJob"; + return new JobBuilder(JOB_NAME, jobRepository) + .start(sendQuickStartEmailsStep()) + .build(); + } + + public Step sendQuickStartEmailsStep() { + final String STEP_NAME = "sendQuickStartEmailsStep"; + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(sendQuickStartEmailsTasklet(), platformTransactionManager) + .build(); + } + + public Tasklet sendQuickStartEmailsTasklet() { + return (contribution, chunkContext) -> { + try { + List receivers = collectEmailReceiversWithinTimeRange(); + if (!receivers.isEmpty()) { + List earliestQuickStarts = collectEarliestQuickStartsForMembers(receivers); + sendEmailsToReceivers(receivers, earliestQuickStarts); + } else { + log.warn("[SendQuickStartEmailsJob] No valid receiver found in the time range."); + } + } catch (Exception e) { + log.error("[SendQuickStartEmailsJob] Error during tasklet execution", e); + } + return RepeatStatus.FINISHED; + }; + } + + private List collectEmailReceiversWithinTimeRange() { + LocalTime lowerBound = getLowerBound(); + LocalTime upperBound = getUpperBound(lowerBound); + + return memberJpaRepository.findMembersForQuickStartsInTimeRange(getLowerBound(), upperBound); + } + + private List collectEarliestQuickStartsForMembers(List receivers) { + LocalTime lowerBound = getLowerBound(); + LocalTime upperBound = getUpperBound(lowerBound); + + List receiverIds = receivers.stream() + .map(MemberJpaEntity::getId) + .collect(Collectors.toList()); + return quickStartJpaRepository.findEarliestQuickStartsForMembers(receiverIds, lowerBound, upperBound); + } + + private void sendEmailsToReceivers(List receivers, List earliestQuickStarts) { + for (MemberJpaEntity receiver : receivers) { + QuickStartJpaEntity earliestQuickStart = findEarliestQuickStartForReceiver(receiver, earliestQuickStarts); + if (earliestQuickStart != null) { + String title = generateEmailTitle(receiver, earliestQuickStart); + SendEmailRequest request = SendEmailRequest.builder() + .title(title) + .content(generateEmailContent()) + .receiver(receiver.getEmail()) + .build(); + try { + emailUtil.send(request); + log.info("[SendQuickStartEmailsJob] Successfully sent email to {}", receiver); + } catch (Exception e) { + log.error("[SendQuickStartEmailsJob] Failed to send email to {}", receiver, e); + } + } + } + } + + private QuickStartJpaEntity findEarliestQuickStartForReceiver(MemberJpaEntity receiver, List earliestQuickStarts) { + return earliestQuickStarts.stream() + .filter(q -> q.getMemberId().equals(receiver.getId())) + .findFirst() + .orElse(null); + } + + private String generateEmailTitle(MemberJpaEntity receiver, QuickStartJpaEntity earliestQuickStart) { + return "%s 님, %s의 시간 조각을 모으러 갈 시간이에요 ⏰".formatted(receiver.getNickname(), earliestQuickStart.getName()); + } + + private String generateEmailContent() { + Context context = new Context(); + return templateEngine.process("mail", context); + } + + private LocalTime getLowerBound() { + LocalTime now = LocalTime.now(); + return now.plusMinutes(1).withSecond(0).withNano(0); + } + + private LocalTime getUpperBound(LocalTime lowerBound) { + return lowerBound.plusMinutes(TIME_INTERVAL_MINUTES - 1); + } +} diff --git a/src/main/java/spring/backend/quickstart/infrastructure/mapper/QuickStartMapper.java b/src/main/java/spring/backend/quickstart/infrastructure/mapper/QuickStartMapper.java new file mode 100644 index 000000000..f91174d60 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/infrastructure/mapper/QuickStartMapper.java @@ -0,0 +1,39 @@ +package spring.backend.quickstart.infrastructure.mapper; + +import org.springframework.stereotype.Component; +import spring.backend.quickstart.domain.entity.QuickStart; +import spring.backend.quickstart.infrastructure.persistence.jpa.entity.QuickStartJpaEntity; + +import java.util.Optional; + +@Component +public class QuickStartMapper { + + public QuickStart toDomainEntity(QuickStartJpaEntity quickStart) { + return QuickStart.builder() + .id(quickStart.getId()) + .memberId(quickStart.getMemberId()) + .name(quickStart.getName()) + .startTime(quickStart.getStartTime()) + .spareTime(quickStart.getSpareTime()) + .type(quickStart.getType()) + .createdAt(quickStart.getCreatedAt()) + .updatedAt(quickStart.getUpdatedAt()) + .deleted(quickStart.getDeleted()) + .build(); + } + + public QuickStartJpaEntity toJpaEntity(QuickStart quickStart) { + return QuickStartJpaEntity.builder() + .id(quickStart.getId()) + .memberId(quickStart.getMemberId()) + .name(quickStart.getName()) + .startTime(quickStart.getStartTime()) + .spareTime(quickStart.getSpareTime()) + .type(quickStart.getType()) + .createdAt(quickStart.getCreatedAt()) + .updatedAt(quickStart.getUpdatedAt()) + .deleted(Optional.ofNullable(quickStart.getDeleted()).orElse(false)) + .build(); + } +} diff --git a/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/adapter/QuickStartRepositoryImpl.java b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/adapter/QuickStartRepositoryImpl.java new file mode 100644 index 000000000..d31ee56db --- /dev/null +++ b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/adapter/QuickStartRepositoryImpl.java @@ -0,0 +1,57 @@ +package spring.backend.quickstart.infrastructure.persistence.jpa.adapter; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Repository; +import spring.backend.quickstart.domain.entity.QuickStart; +import spring.backend.quickstart.domain.repository.QuickStartRepository; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.quickstart.infrastructure.mapper.QuickStartMapper; +import spring.backend.quickstart.infrastructure.persistence.jpa.entity.QuickStartJpaEntity; +import spring.backend.quickstart.infrastructure.persistence.jpa.repository.QuickStartJpaRepository; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +@Log4j2 +public class QuickStartRepositoryImpl implements QuickStartRepository { + + private final QuickStartMapper quickStartMapper; + private final QuickStartJpaRepository quickStartJpaRepository; + + @Override + public QuickStart findById(Long id) { + QuickStartJpaEntity quickStartJpaEntity = quickStartJpaRepository.findById(id).orElse(null); + if (quickStartJpaEntity == null) { + return null; + } + return quickStartMapper.toDomainEntity(quickStartJpaEntity); + } + + @Override + public QuickStart save(QuickStart quickStart) { + try { + QuickStartJpaEntity quickStartJpaEntity = quickStartMapper.toJpaEntity(quickStart); + quickStartJpaRepository.save(quickStartJpaEntity); + return quickStartMapper.toDomainEntity(quickStartJpaEntity); + } catch (Exception e) { + log.error("[QuickStartRepositoryImpl] Failed to save quickStart", e); + throw QuickStartErrorCode.QUICK_START_SAVE_FAILED.toException(); + } + } + + @Override + public List findEarliestQuickStartsForMembers(List memberIds, LocalTime lowerBound, LocalTime upperBound) { + List quickStartJpaEntities = quickStartJpaRepository.findEarliestQuickStartsForMembers(memberIds, lowerBound, upperBound); + if (quickStartJpaEntities == null || quickStartJpaEntities.isEmpty()) { + return null; + } + return quickStartJpaEntities.stream() + .map(quickStartMapper::toDomainEntity) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/dao/QuickStartJpaDao.java b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/dao/QuickStartJpaDao.java new file mode 100644 index 000000000..ca3794dc8 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/dao/QuickStartJpaDao.java @@ -0,0 +1,46 @@ +package spring.backend.quickstart.infrastructure.persistence.jpa.dao; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import spring.backend.quickstart.dto.response.QuickStartResponse; +import spring.backend.quickstart.infrastructure.persistence.jpa.entity.QuickStartJpaEntity; +import spring.backend.quickstart.query.dao.QuickStartDao; + +import java.util.List; +import java.util.UUID; + +public interface QuickStartJpaDao extends JpaRepository, QuickStartDao { + + @Override + @Query(""" + select new spring.backend.quickstart.dto.response.QuickStartResponse( + q.id, + q.name, + q.startTime, + q.spareTime, + q.type + ) + from QuickStartJpaEntity q + where q.memberId = :memberId + """) + List findByMemberId(UUID memberId, Sort sort); + + @Override + @Query(""" + select new spring.backend.quickstart.dto.response.QuickStartResponse( + q.id, + q.name, + q.startTime, + q.spareTime, + q.type + ) + from QuickStartJpaEntity q + where q.memberId = :memberId + and (q.startTime > current_time or q.startTime <= current_time) + order by + case when q.startTime > current_time then 0 else 1 end, + q.startTime asc + """) + List findUpcomingQuickStarts(UUID memberId); +} diff --git a/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/entity/QuickStartJpaEntity.java b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/entity/QuickStartJpaEntity.java new file mode 100644 index 000000000..96300a036 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/entity/QuickStartJpaEntity.java @@ -0,0 +1,34 @@ +package spring.backend.quickstart.infrastructure.persistence.jpa.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import spring.backend.activity.domain.value.Type; +import spring.backend.core.infrastructure.jpa.shared.BaseLongIdEntity; + +import java.time.LocalTime; +import java.util.UUID; + +@Entity +@Table(name = "quick_start") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuickStartJpaEntity extends BaseLongIdEntity { + + private UUID memberId; + + private String name; + + private LocalTime startTime; + + private Integer spareTime; + + @Enumerated(EnumType.STRING) + private Type type; +} diff --git a/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/repository/QuickStartJpaRepository.java b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/repository/QuickStartJpaRepository.java new file mode 100644 index 000000000..dd487da18 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/infrastructure/persistence/jpa/repository/QuickStartJpaRepository.java @@ -0,0 +1,21 @@ +package spring.backend.quickstart.infrastructure.persistence.jpa.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import spring.backend.quickstart.infrastructure.persistence.jpa.entity.QuickStartJpaEntity; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +public interface QuickStartJpaRepository extends JpaRepository { + + @Query(""" + select q + from QuickStartJpaEntity q + where q.memberId in :memberIds + and q.startTime between :lowerBound and :upperBound + order by q.startTime asc + """) + List findEarliestQuickStartsForMembers(List memberIds, LocalTime lowerBound,LocalTime upperBound); +} diff --git a/src/main/java/spring/backend/quickstart/presentation/CreateQuickStartController.java b/src/main/java/spring/backend/quickstart/presentation/CreateQuickStartController.java new file mode 100644 index 000000000..a5fabde5c --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/CreateQuickStartController.java @@ -0,0 +1,29 @@ +package spring.backend.quickstart.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.quickstart.domain.service.CreateQuickStartService; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; +import spring.backend.quickstart.presentation.swagger.CreateQuickStartSwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class CreateQuickStartController implements CreateQuickStartSwagger { + + private final CreateQuickStartService createQuickStartService; + + @Authorization + @PostMapping("/v1/quick-starts") + public ResponseEntity> createQuickStart(@AuthorizedMember Member member, @Valid @RequestBody QuickStartRequest request) { + Long memberId = createQuickStartService.createQuickStart(member, request); + return ResponseEntity.ok(new RestResponse<>(memberId)); + } +} diff --git a/src/main/java/spring/backend/quickstart/presentation/ReadQuickStartsController.java b/src/main/java/spring/backend/quickstart/presentation/ReadQuickStartsController.java new file mode 100644 index 000000000..3f7c8beb7 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/ReadQuickStartsController.java @@ -0,0 +1,26 @@ +package spring.backend.quickstart.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.quickstart.application.ReadQuickStartsService; +import spring.backend.quickstart.presentation.dto.response.QuickStartsResponse; +import spring.backend.quickstart.presentation.swagger.ReadQuickStartsSwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class ReadQuickStartsController implements ReadQuickStartsSwagger { + + private final ReadQuickStartsService readQuickStartsService; + + @Authorization + @GetMapping("/v1/quick-starts") + public ResponseEntity> readQuickStarts(@AuthorizedMember Member member) { + return ResponseEntity.ok(new RestResponse<>(readQuickStartsService.readQuickStarts(member))); + } +} diff --git a/src/main/java/spring/backend/quickstart/presentation/UpdateQuickStartController.java b/src/main/java/spring/backend/quickstart/presentation/UpdateQuickStartController.java new file mode 100644 index 000000000..5d5cad395 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/UpdateQuickStartController.java @@ -0,0 +1,29 @@ +package spring.backend.quickstart.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.quickstart.domain.service.UpdateQuickStartService; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; +import spring.backend.quickstart.presentation.swagger.UpdateQuickStartSwagger; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.member.domain.entity.Member; + +@RestController +@RequiredArgsConstructor +public class UpdateQuickStartController implements UpdateQuickStartSwagger { + + private final UpdateQuickStartService updateQuickStartService; + + @Authorization + @PatchMapping("/v1/quick-starts/{quickStartId}") + public ResponseEntity updateQuickStart(@AuthorizedMember Member member, @Valid @RequestBody QuickStartRequest request, @PathVariable Long quickStartId) { + updateQuickStartService.updateQuickStart(member, request, quickStartId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/spring/backend/quickstart/presentation/dto/request/QuickStartRequest.java b/src/main/java/spring/backend/quickstart/presentation/dto/request/QuickStartRequest.java new file mode 100644 index 000000000..048c60fc1 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/dto/request/QuickStartRequest.java @@ -0,0 +1,42 @@ +package spring.backend.quickstart.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import spring.backend.activity.domain.value.Type; + +public record QuickStartRequest( + + @NotNull(message = "이름은 필수 입력 항목입니다.") + @Pattern(regexp = "^(?!\\s)([a-zA-Z0-9가-힣ㄱ-ㅎ]+(\\s[a-zA-Z0-9가-힣ㄱ-ㅎ]+)*)?$", message = "이름은 한글(초성 포함), 영문, 숫자 및 공백만 입력 가능하며, 공백으로 시작하거나 끝날 수 없고, 연속된 공백이 없어야 합니다.") + @Size(max = 10, message = "최대 10자까지 입력 가능합니다.") + @Schema(description = "빠른 시작 이름", example = "등교") + String name, + + @NotNull(message = "시작 시간의 시간은 필수 입력 항목입니다.") + @Min(value = 0, message = "시작 시간의 시간은 최소 0이어야 합니다.") + @Max(value = 12, message = "시작 시간의 시간은 최대 12이어야 합니다.") + @Schema(description = "시작 시간의 시간", example = "12") + Integer hour, + + @NotNull(message = "시작 시간의 분은 필수 입력 항목입니다.") + @Min(value = 0, message = "시작 시간의 분은 최소 0이어야 합니다.") + @Max(value = 59, message = "시작 시간의 분은 최대 59이어야 합니다.") + @Schema(description = "시작 시간의 분", example = "30") + Integer minute, + + @NotNull(message = "오전/오후 표시는 필수 입력 항목입니다.") + @Pattern(regexp = "^(오전|오후)$", message = "meridiem은 '오전' 또는 '오후'여야 합니다.") + @Schema(description = "오전/오후 표시", example = "오후") + String meridiem, + + @NotNull(message = "자투리 시간은 필수 입력 항목입니다.") + @Min(value = 10, message = "자투리 시간은 최소 10이어야 합니다.") + @Max(value = 300, message = "자투리 시간은 최대 300이어야 합니다.") + @Schema(description = "자투리 시간", example = "300") + Integer spareTime, + + @NotNull(message = "활동 유형은 필수 입력 항목입니다.") + @Schema(description = "활동 유형 (ONLINE, OFFLINE, ONLINE_AND_OFFLINE)", example = "ONLINE") + Type type +) { +} diff --git a/src/main/java/spring/backend/quickstart/presentation/dto/response/QuickStartsResponse.java b/src/main/java/spring/backend/quickstart/presentation/dto/response/QuickStartsResponse.java new file mode 100644 index 000000000..a10230b44 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/dto/response/QuickStartsResponse.java @@ -0,0 +1,13 @@ +package spring.backend.quickstart.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.quickstart.dto.response.QuickStartResponse; + +import java.util.List; + +public record QuickStartsResponse( + + @Schema(description = "빠른 시작 리스트") + List quickStartResponses +) { +} diff --git a/src/main/java/spring/backend/quickstart/presentation/swagger/CreateQuickStartSwagger.java b/src/main/java/spring/backend/quickstart/presentation/swagger/CreateQuickStartSwagger.java new file mode 100644 index 000000000..43d718169 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/swagger/CreateQuickStartSwagger.java @@ -0,0 +1,24 @@ +package spring.backend.quickstart.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "QuickStart", description = "빠른 시작") +public interface CreateQuickStartSwagger { + + @Operation( + summary = "빠른 시작 생성 API", + description = "빠른 시작을 생성합니다.", + operationId = "/v1/quick-starts" + ) + @ApiErrorCode({GlobalErrorCode.class, QuickStartErrorCode.class}) + ResponseEntity> createQuickStart(@Parameter(hidden = true) Member member, QuickStartRequest request); +} diff --git a/src/main/java/spring/backend/quickstart/presentation/swagger/ReadQuickStartsSwagger.java b/src/main/java/spring/backend/quickstart/presentation/swagger/ReadQuickStartsSwagger.java new file mode 100644 index 000000000..f38b1df5c --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/swagger/ReadQuickStartsSwagger.java @@ -0,0 +1,24 @@ +package spring.backend.quickstart.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.quickstart.presentation.dto.response.QuickStartsResponse; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "QuickStart", description = "빠른 시작") +public interface ReadQuickStartsSwagger { + + @Operation( + summary = "빠른 시작 리스트 조회 API", + description = "사용자가 생성한 빠른 시작 리스트를 반환합니다.", + operationId = "/v1/quick-starts" + ) + @ApiErrorCode({GlobalErrorCode.class, QuickStartErrorCode.class}) + ResponseEntity> readQuickStarts(@Parameter(hidden = true) Member member); +} diff --git a/src/main/java/spring/backend/quickstart/presentation/swagger/UpdateQuickStartSwagger.java b/src/main/java/spring/backend/quickstart/presentation/swagger/UpdateQuickStartSwagger.java new file mode 100644 index 000000000..af8204c9a --- /dev/null +++ b/src/main/java/spring/backend/quickstart/presentation/swagger/UpdateQuickStartSwagger.java @@ -0,0 +1,23 @@ +package spring.backend.quickstart.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.member.domain.entity.Member; + +@Tag(name = "QuickStart", description = "빠른 시작") +public interface UpdateQuickStartSwagger { + + @Operation( + summary = "빠른 시작 수정 API", + description = "빠른 시작 ID를 통해 빠른 시작을 수정합니다.", + operationId = "/v1/quick-starts/{quickStartId}" + ) + @ApiErrorCode({GlobalErrorCode.class, QuickStartErrorCode.class}) + ResponseEntity updateQuickStart(@Parameter(hidden = true) Member member, QuickStartRequest request, Long quickStartId); +} diff --git a/src/main/java/spring/backend/quickstart/query/dao/QuickStartDao.java b/src/main/java/spring/backend/quickstart/query/dao/QuickStartDao.java new file mode 100644 index 000000000..025b80462 --- /dev/null +++ b/src/main/java/spring/backend/quickstart/query/dao/QuickStartDao.java @@ -0,0 +1,14 @@ +package spring.backend.quickstart.query.dao; + +import org.springframework.data.domain.Sort; +import spring.backend.quickstart.dto.response.QuickStartResponse; + +import java.util.List; +import java.util.UUID; + +public interface QuickStartDao { + + List findByMemberId(UUID memberId, Sort sort); + + List findUpcomingQuickStarts(UUID memberId); +} diff --git a/src/main/java/spring/backend/recommendation/application/GetRecommendationsFromClovaService.java b/src/main/java/spring/backend/recommendation/application/GetRecommendationsFromClovaService.java new file mode 100644 index 000000000..201598613 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/application/GetRecommendationsFromClovaService.java @@ -0,0 +1,225 @@ +package spring.backend.recommendation.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Keyword.Category; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.core.converter.ImageConverter; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; +import spring.backend.recommendation.presentation.dto.response.ClovaRecommendationResponse; +import spring.backend.recommendation.infrastructure.clova.dto.response.ClovaResponse; +import spring.backend.recommendation.infrastructure.clova.exception.ClovaErrorCode; +import spring.backend.recommendation.infrastructure.map.kakao.dto.response.KakaoMapResponse; + +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static spring.backend.activity.domain.value.Type.*; + +@Service +@Log4j2 +@RequiredArgsConstructor +public class GetRecommendationsFromClovaService { + private static final int MAX_ATTEMPTS = 1; + + private static final Pattern TITLE_FULL_LINE_PATTERN = Pattern.compile(".*title\\s*:.*"); + private static final Pattern TITLE_PREFIX_PATTERN = Pattern.compile(".*title\\s*:"); + private static final Pattern PLACE_NAME_PREFIX_PATTERN = Pattern.compile(".*placeName\\s*:"); + private static final Pattern CONTENT_PREFIX_PATTERN = Pattern.compile(".*content\\s*:"); + private static final Pattern KEYWORD_PREFIX_PATTERN = Pattern.compile(".*keyword\\s*:"); + private static final String LINE_SEPARATOR = "\n"; + private static final int ONLINE_AND_OFFLINE_RECOMMENDATION_COUNT = 3; + + @Value("${kakao.map-uri}") + private String kakaoMapUri; + + private final RecommendationProvider recommendationProvider; + private final PlaceInfoProvider kakaomapPlaceInfoProvider; + private final ImageConverter imageConverter; + private static final Random RANDOM = new Random(); + + public List getRecommendationsFromClova(AIRecommendationRequest clovaRecommendationRequest) { + validateLocation(clovaRecommendationRequest); + List clovaResponses = fetchRecommendations(clovaRecommendationRequest); + int attempt = 1; + + while (containsInvalidKeyword(clovaResponses) && attempt <= MAX_ATTEMPTS) { + log.warn("추천활동의 키워드가 올바르지 않습니다. 재시도 횟수: {}/{}", attempt, MAX_ATTEMPTS); + clovaResponses = fetchRecommendations(clovaRecommendationRequest); + attempt++; + } + + List validRecommendations = filteredValidRecommendations(clovaResponses); + + if (validRecommendations.isEmpty()) { + throw ClovaErrorCode.INVALID_KEYWORD_IN_RECOMMENDATIONS.toException(); + } + + if (clovaRecommendationRequest.activityType() == ONLINE_AND_OFFLINE) { + return validRecommendations.stream() + .limit(ONLINE_AND_OFFLINE_RECOMMENDATION_COUNT) + .collect(Collectors.toList()); + } + + return validRecommendations; + + } + + private List filteredValidRecommendations(List clovaResponses) { + return clovaResponses.stream() + .filter(clovaResponse -> clovaResponse.getKeyword() != null && isValidKeywordCategory(clovaResponse.getKeyword().getCategory())).collect(Collectors.toList()); + } + + private List fetchRecommendations(AIRecommendationRequest clovaRecommendationRequest) { + AIRecommendationRequest filteredClovaRecommendationRequest = filteredValidRecommendations(clovaRecommendationRequest); + validateClovaRecommendationRequestKeyword(filteredClovaRecommendationRequest); + ClovaResponse clovaResponse = recommendationProvider.getRecommendations(filteredClovaRecommendationRequest); + validateClovaResponse(clovaResponse); + String parsedClovaResponse = clovaResponse.getResult().getMessage().getContent(); + String[] recommendations = parsedClovaResponse.split(LINE_SEPARATOR); + + List clovaResponses = new ArrayList<>(); + int order = 1; + + for (int i = 0; i < recommendations.length; i++) { + String line = recommendations[i].trim(); + + if (TITLE_FULL_LINE_PATTERN.matcher(line).matches()) { + String title = TITLE_PREFIX_PATTERN.matcher(line).replaceFirst("").trim(); + if (i + 1 < recommendations.length && recommendations[i + 1].trim().startsWith("http")) { + title += " " + recommendations[i + 1].trim(); + i++; + } + + String placeName = "", placeUrl = "", mapx = "", mapy = ""; + + if (i + 1 < recommendations.length && PLACE_NAME_PREFIX_PATTERN.matcher(recommendations[i + 1].trim()).find()) { + placeName = PLACE_NAME_PREFIX_PATTERN.matcher(recommendations[i + 1].trim()).replaceFirst("").trim(); + KakaoMapResponse placeInfo = kakaomapPlaceInfoProvider.search(placeName); + + if (placeInfo.documents() != null && !placeInfo.documents().isEmpty()) { + mapx = placeInfo.documents().get(0).x(); + mapy = placeInfo.documents().get(0).y(); + if (Objects.equals(placeInfo.documents().get(0).placeUrl(), "")) { + placeUrl = kakaoMapUri; + } else { + placeUrl = placeInfo.documents().get(0).placeUrl(); + } + } + + i++; + } + + String content = ""; + if (i + 1 < recommendations.length && CONTENT_PREFIX_PATTERN.matcher(recommendations[i + 1].trim()).find()) { + content = CONTENT_PREFIX_PATTERN.matcher(recommendations[i + 1].trim()).replaceFirst("").trim(); + i++; + } + + Keyword keyword = null; + if (i + 1 < recommendations.length && KEYWORD_PREFIX_PATTERN.matcher(recommendations[i + 1].trim()).find()) { + String keywordText = KEYWORD_PREFIX_PATTERN.matcher(recommendations[i + 1].trim()).replaceFirst("").trim(); + Category category = parsedKeywordTextToCategory(keywordText); + keyword = Keyword.create(category, imageConverter.convertToImageUrl(category)); + i++; + } + clovaResponses.add(new ClovaRecommendationResponse(order, title, placeName, mapx, mapy, placeUrl, content, keyword)); + order++; + } + } + + return clovaResponses; + } + + private Category parsedKeywordTextToCategory(String keywordText) { + if (keywordText == null || keywordText.isEmpty()) { + return null; + } + + List validKeywordCategories = Arrays.stream(keywordText.split(",")) + .map(String::trim) + .map(this::convertClovaResponseKeywordToKeywordCategory) + .toList(); + + if (validKeywordCategories.isEmpty()) { + return null; + } + + RANDOM.setSeed(System.nanoTime()); + int randomIdx = RANDOM.nextInt(validKeywordCategories.size()); + return validKeywordCategories.get(randomIdx); + } + + private Category convertClovaResponseKeywordToKeywordCategory(String keywordText) { + if (keywordText == null || keywordText.isEmpty()) { + return null; + } + try { + return Category.valueOf(keywordText.trim()); + } catch (IllegalArgumentException e) { + return Arrays.stream(Category.values()) + .filter(category -> category.getDescription().equals(keywordText)) + .findFirst() + .orElse(null); + } + } + + private AIRecommendationRequest filteredValidRecommendations(AIRecommendationRequest clovaRecommendationRequest) { + if (clovaRecommendationRequest.keywords() == null) { + return clovaRecommendationRequest; + } + + Keyword.Category[] filteredKeywords = Arrays.stream(clovaRecommendationRequest.keywords()) + .filter(category -> category != Keyword.Category.SOCIAL) + .toArray(Keyword.Category[]::new); + + return new AIRecommendationRequest( + clovaRecommendationRequest.spareTime(), + clovaRecommendationRequest.activityType(), + filteredKeywords, + clovaRecommendationRequest.location() + ); + } + + private void validateLocation(AIRecommendationRequest clovaRecommendationRequest) { + if ((clovaRecommendationRequest.activityType() == OFFLINE || clovaRecommendationRequest.activityType() == ONLINE_AND_OFFLINE) && + (clovaRecommendationRequest.location() == null || clovaRecommendationRequest.location().isEmpty())) { + log.error("[AIRecommendationRequest] location must exist when activityType is OFFLINE or ONLINE_AND_OFFLINE"); + throw ActivityErrorCode.NOT_EXIST_LOCATION_WHEN_OFFLINE.toException(); + } + + if (clovaRecommendationRequest.activityType() == ONLINE && clovaRecommendationRequest.location() != null && !clovaRecommendationRequest.location().isEmpty()) { + log.error("[AIRecommendationRequest] location must not exist when activityType is ONLINE"); + throw ActivityErrorCode.EXIST_LOCATION_WHEN_ONLINE.toException(); + } + } + + private void validateClovaResponse(ClovaResponse clovaResponse) { + if (clovaResponse == null || clovaResponse.getResult() == null || clovaResponse.getResult().getMessage() == null || clovaResponse.getResult().getMessage().getContent() == null) { + log.error("Clova 서비스로부터 null 응답을 수신했습니다."); + throw ClovaErrorCode.NULL_RESPONSE_FROM_CLOVA.toException(); + } + } + + private boolean containsInvalidKeyword(List clovaResponses) { + return clovaResponses.stream().anyMatch(clovaResponse -> + clovaResponse.getKeyword() == null + || !isValidKeywordCategory(clovaResponse.getKeyword().getCategory())); + } + + private boolean isValidKeywordCategory(Category keywordCategory) { + return Arrays.stream(Category.values()).anyMatch(category -> category == keywordCategory); + } + + private void validateClovaRecommendationRequestKeyword(AIRecommendationRequest clovaRecommendationRequest) { + if (clovaRecommendationRequest.activityType().equals(OFFLINE) && Arrays.asList(clovaRecommendationRequest.keywords()).contains(Category.SOCIAL) + ) { + throw ClovaErrorCode.OFFLINE_TYPE_CONTAIN_SOCIAL.toException(); + } + } + +} diff --git a/src/main/java/spring/backend/recommendation/application/GetRecommendationsFromOpenAIService.java b/src/main/java/spring/backend/recommendation/application/GetRecommendationsFromOpenAIService.java new file mode 100644 index 000000000..f748286e4 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/application/GetRecommendationsFromOpenAIService.java @@ -0,0 +1,178 @@ +package spring.backend.recommendation.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Keyword.Category; +import spring.backend.activity.domain.value.Type; +import spring.backend.core.converter.ImageConverter; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; +import spring.backend.recommendation.presentation.dto.response.OpenAIRecommendationResponse; +import spring.backend.recommendation.infrastructure.dto.Message; +import spring.backend.recommendation.infrastructure.openai.dto.response.OpenAIResponse; +import spring.backend.recommendation.infrastructure.openai.dto.response.OpenAIResponse.Choice; +import spring.backend.recommendation.infrastructure.openai.exception.OpenAIErrorCode; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class GetRecommendationsFromOpenAIService { + + private static final String LINE_SEPARATOR = "\n"; + private static final Pattern RECOMMENDATION_FIELD_PATTERN = Pattern.compile("^(title|content|keyword|platform|url):\\s*(.*)$"); + private static final Pattern LINK_SEARCH_QUERY_PATTERN = Pattern.compile("'(.*?)'"); + private static final String TITLE_KEY = "title"; + private static final String CONTENT_KEY = "content"; + private static final String KEYWORD_KEY = "keyword"; + private static final String PLATFORM_KEY = "platform"; + private static final String URL_KEY = "url"; + private static final String YOUTUBE_PLATFORM = "youtube"; + private static final int ONLINE_REQUIRED_SIZE = 5; + private static final int ONLINE_OFFLINE_OPENAI_REQUIRED_SIZE = 2; + private static final int MAX_RETRY_ATTEMPTS = 2; + + private final RecommendationProvider> openAIRecommendationProvider; + private final SearchYouTubeService searchYouTubeService; + private final ImageConverter imageConverter; + + public List getRecommendationsFromOpenAI(AIRecommendationRequest request) { + for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + try { + OpenAIResponse openAIResponse = openAIRecommendationProvider.getRecommendations(request).block(); + if (openAIResponse == null) { + throw OpenAIErrorCode.NOT_FOUND_RECOMMENDATION.toException(); + } + + String rawData = extractRecommendationContent(openAIResponse); + List recommendations = parseRecommendations(rawData, request.activityType()); + + if (attempt == 1 && (recommendations.isEmpty() || isKeywordMissing(recommendations))) { + log.warn("[GetRecommendationsFromOpenAIService] First attempt failed : {}", recommendations); + continue; + } + + return recommendations; + + } catch (Exception e) { + if (attempt == MAX_RETRY_ATTEMPTS) { + log.error("[GetRecommendationsFromOpenAIService] Maximum retry attempts exceeded.", e); + throw OpenAIErrorCode.FAILED_RECOMMENDATION_GENERATION.toException(); + } + log.warn("[GetRecommendationsFromOpenAIService] Invalid response received. Retrying attempt: {}", attempt + 1, e); + } + } + throw OpenAIErrorCode.FAILED_RECOMMENDATION_GENERATION.toException(); + } + + private String extractRecommendationContent(OpenAIResponse openAIResponse) { + return Optional.ofNullable(openAIResponse) + .map(OpenAIResponse::choices) + .map(choices -> choices.get(0)) + .map(Choice::message) + .map(Message::content) + .orElse(null); + } + + private List parseRecommendations(String rawData, Type activityType) { + if (rawData == null || rawData.isEmpty()) { + throw OpenAIErrorCode.NOT_FOUND_RECOMMENDATION.toException(); + } + + String[] lines = rawData.split(LINE_SEPARATOR); + List recommendations = new ArrayList<>(); + int order = 1; + + Map recommendationFields = new HashMap<>(); + + for (String line : lines) { + Matcher matcher = RECOMMENDATION_FIELD_PATTERN.matcher(line); + + if (matcher.matches()) { + recommendationFields.put(matcher.group(1).toLowerCase(), matcher.group(2).trim()); + } + + if (hasAllRequiredFields(recommendationFields)) { + String keywordText = recommendationFields.get(KEYWORD_KEY); + Category category = Category.from(keywordText); + + if (isInvalidCategory(category)) { + log.warn("[GetRecommendationsFromOpenAIService] Invalid Category."); + recommendationFields.clear(); + continue; + } + + String title = recommendationFields.get(TITLE_KEY); + String platform = recommendationFields.get(PLATFORM_KEY); + String url = recommendationFields.get(URL_KEY); + Keyword keyword = Keyword.create(category, imageConverter.convertToImageUrl(category)); + String youtubeUrl = processYoutubeUrl(title, platform, url); + + recommendations.add(OpenAIRecommendationResponse.of( + order++, + title, + recommendationFields.get(CONTENT_KEY), + keyword, + youtubeUrl + )); + + recommendationFields.clear(); + } + } + + return filterAndLimitRecommendations(recommendations, activityType); + } + + private List filterAndLimitRecommendations(List recommendations, Type activityType) { + return recommendations.stream() + .filter(r -> r.keyword() != null) + .limit(getRequiredSize(activityType)) + .collect(Collectors.toList()); + } + + private int getRequiredSize(Type activityType) { + return switch (activityType) { + case ONLINE -> ONLINE_REQUIRED_SIZE; + case ONLINE_AND_OFFLINE -> ONLINE_OFFLINE_OPENAI_REQUIRED_SIZE; + default -> 0; + }; + } + + private boolean isInvalidCategory(Category category) { + return category == null; + } + + private boolean isKeywordMissing(List recommendations) { + return recommendations.stream() + .anyMatch(r -> isInvalidCategory(r.keyword().getCategory())); + } + + private boolean hasAllRequiredFields(Map recommendationFields) { + return recommendationFields.containsKey(TITLE_KEY) + && recommendationFields.containsKey(CONTENT_KEY) + && recommendationFields.containsKey(KEYWORD_KEY) + && recommendationFields.containsKey(PLATFORM_KEY) + && recommendationFields.containsKey(URL_KEY); + } + + private String processYoutubeUrl(String title, String platform, String url) { + if (YOUTUBE_PLATFORM.equalsIgnoreCase(platform)) { + String youtubeUrl = searchYouTubeService.searchYoutube(extractYoutubeUrlFromTitle(title)); + if (youtubeUrl != null) { + return youtubeUrl; + } + } + return url; + } + + private String extractYoutubeUrlFromTitle(String title) { + Matcher matcher = LINK_SEARCH_QUERY_PATTERN.matcher(title); + return matcher.find() ? matcher.group(1) : null; + } +} diff --git a/src/main/java/spring/backend/recommendation/application/PlaceInfoProvider.java b/src/main/java/spring/backend/recommendation/application/PlaceInfoProvider.java new file mode 100644 index 000000000..d674c4920 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/application/PlaceInfoProvider.java @@ -0,0 +1,5 @@ +package spring.backend.recommendation.application; + +public interface PlaceInfoProvider { + T search(String query); +} diff --git a/src/main/java/spring/backend/recommendation/application/RecommendationProvider.java b/src/main/java/spring/backend/recommendation/application/RecommendationProvider.java new file mode 100644 index 000000000..8e9d02aa5 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/application/RecommendationProvider.java @@ -0,0 +1,7 @@ +package spring.backend.recommendation.application; + +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; + +public interface RecommendationProvider { + T getRecommendations(AIRecommendationRequest aiRecommendationRequest); +} diff --git a/src/main/java/spring/backend/recommendation/application/SearchYouTubeService.java b/src/main/java/spring/backend/recommendation/application/SearchYouTubeService.java new file mode 100644 index 000000000..a8ab25d54 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/application/SearchYouTubeService.java @@ -0,0 +1,41 @@ +package spring.backend.recommendation.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import spring.backend.recommendation.infrastructure.link.LinkWebClient; +import spring.backend.recommendation.infrastructure.link.youtube.dto.response.YoutubeResponse; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class SearchYouTubeService { + + @Value("${youtube.video-url}") + private String youtubeVideoUrl; + + private final LinkWebClient youtubeLinkWebClient; + + public String searchYoutube(String query) { + if (query == null || query.isEmpty()) { + log.error("[SearchYouTubeService]: query is empty"); + return null; + } + YoutubeResponse youtubeResponse = youtubeLinkWebClient.search(query); + if (youtubeResponse == null || youtubeResponse.items() == null) { + log.error("[SearchYouTubeService]: youtubeResponse is null"); + return null; + } + String videoId = youtubeResponse.items().stream() + .filter(item -> item.id() != null && item.id().videoId() != null) + .map(item -> item.id().videoId()) + .findFirst() + .orElse(null); + if (videoId == null) { + log.error("[SearchYouTubeService]: videoId is empty"); + return null; + } + return youtubeVideoUrl + videoId; + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/clova/application/ClovaRecommendationProvider.java b/src/main/java/spring/backend/recommendation/infrastructure/clova/application/ClovaRecommendationProvider.java new file mode 100644 index 000000000..8fc784ea3 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/clova/application/ClovaRecommendationProvider.java @@ -0,0 +1,58 @@ +package spring.backend.recommendation.infrastructure.clova.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.recommendation.application.RecommendationProvider; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; +import spring.backend.recommendation.infrastructure.clova.dto.request.ClovaRequest; +import spring.backend.recommendation.infrastructure.clova.dto.response.ClovaResponse; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class ClovaRecommendationProvider implements RecommendationProvider { + + @Value("${clova.api.url}") + private String apiUrl; + + @Value("${clova.api.api-key}") + private String apiKey; + + @Value("${clova.api.api-gateway-key}") + private String apiGatewayKey; + + @Override + public ClovaResponse getRecommendations(AIRecommendationRequest clovaRecommendationRequest) { + try { + ClovaRequest request = ClovaRequest.createClovaRequest(clovaRecommendationRequest); + + WebClient webClient = WebClient.builder() + .defaultHeaders(httpHeaders -> { + httpHeaders.set("X-NCP-CLOVASTUDIO-API-KEY", apiKey); + httpHeaders.set("X-NCP-APIGW-API-KEY", apiGatewayKey); + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + }) + .build(); + + return webClient + .post() + .uri(apiUrl) + .bodyValue(request) + .retrieve() + .bodyToMono(ClovaResponse.class) + .block(); + } catch (WebClientException e) { + log.error("WebClient 에러 발생 - 에러 메시지: {}", e.getMessage(), e); + throw GlobalErrorCode.WEB_CLIENT_ERROR.toException(); + } catch (Exception e) { + log.error("알 수 없는 내부 오류 발생 - 에러 메시지: {}", e.getMessage(), e); + throw GlobalErrorCode.INTERNAL_ERROR.toException(); + } + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/request/ClovaRequest.java b/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/request/ClovaRequest.java new file mode 100644 index 000000000..3c7ebdaab --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/request/ClovaRequest.java @@ -0,0 +1,46 @@ +package spring.backend.recommendation.infrastructure.clova.dto.request; + +import lombok.Builder; +import lombok.Getter; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; +import spring.backend.recommendation.infrastructure.dto.Message; + +import java.util.ArrayList; + +@Builder +@Getter +public class ClovaRequest { + private static final double DEFAULT_TOP_P = 0.8; + private static final int DEFAULT_TOP_K = 0; + private static final int DEFAULT_MAX_TOKENS = 1000; + private static final double DEFAULT_TEMPERATURE = 0.5; + private static final double DEFAULT_REPEAT_PENALTY = 5.0; + private static final boolean DEFAULT_INCLUDE_AI_FILTERS = true; + private static final int DEFAULT_SEED = 0; + + private ArrayList messages; + private double topP; + private int topK; + private int maxTokens; + private double temperature; + private double repeatPenalty; + private boolean includeAiFilters; + private int seed; + + public static ClovaRequest createClovaRequest(AIRecommendationRequest aiRecommendationRequest) { + ArrayList messages = new ArrayList<>(); + messages.add(Message.createSystem(ClovaStudioPrompt.DEFAULT_SYSTEM_PROMPT)); + messages.add(Message.createUserMessage(aiRecommendationRequest)); + + return ClovaRequest.builder() + .messages(messages) + .topP(DEFAULT_TOP_P) + .topK(DEFAULT_TOP_K) + .maxTokens(DEFAULT_MAX_TOKENS) + .temperature(DEFAULT_TEMPERATURE) + .repeatPenalty(DEFAULT_REPEAT_PENALTY) + .includeAiFilters(DEFAULT_INCLUDE_AI_FILTERS) + .seed(DEFAULT_SEED) + .build(); + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/request/ClovaStudioPrompt.java b/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/request/ClovaStudioPrompt.java new file mode 100644 index 000000000..6648e2b37 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/request/ClovaStudioPrompt.java @@ -0,0 +1,64 @@ +package spring.backend.recommendation.infrastructure.clova.dto.request; + +public class ClovaStudioPrompt { + public static final String DEFAULT_SYSTEM_PROMPT = """ + 역할: + 너는 사용자가 입력한 정보를 바탕으로 자투리 시간에 할 수 있는 활동을 추천하는 AI 봇이야. 사용자가 제공하는 정보를 기반으로 적합한 활동을 5가지 추천해줘. 추천은 구체적이고 특정한걸로 되어야하며 추상적이거나 뻔한 활동은 배제해줘. + --- + 입력 정보: + 1. 자투리 시간: 사용자가 활용할 수 있는 시간 (예: 10분, 60분 등). + 2. 활동 타입: + - OFFLINE, ONLINE_AND_OFFLINE: 오프라인 활동 추천 + 3. 활동 키워드: 사용자가 관심 있는 주제 (예: 휴식, 자기개발, 문화/예술, 엔터테인먼트 등). + 4. 장소 : 사용자 위치 + --- + 추천 기준: + 1. 활동 타입이 OFFLINE, ONLINE_AND_OFFLINE일 경우: + - 입력된 활동 키워드, 시간 그리고 장소를 고려하여 다양한 오프라인 활동을 추천. + - 추천되는 활동의 플랫폼은 한국 지역을 추천. + 2. 입력받은 장소에서 5km 이내에 있는 활동 또는 장소를 추천. + --- + 활동 키워드별 정의와 예시: + 1. SELF_DEVELOPMENT + - 정의: 시사상식, 지식, 교양과 관련된 활동으로, 개인의 성장과 발전을 위한 것 + - 예시: 뉴스 기사 읽기, 온라인 강연 보기, 팟캐스트 듣기, 언어 공부하기 등 + 2. ENTERTAINMENT + - 정의: 즐거움과 오락을 목적으로 한 활동, 순간의 재미와 유희를 위한 것 + - 예시: 유튜브 콘텐츠 시청하기, 음악듣기, OTT 시청하기 등 + 3. RELAXATION + - 정의: 신체적, 정신적 피로 회복과 재충전을 위한 정적인 활동 + - 예시: 명상하기, 짧은 글쓰기, ASMR 듣기 등 + 4. CULTURE_ART + - 정의: 예술적, 문화적 경험과 감상을 통해 영감과 인사이트를 얻는 활동 + - 예시: 버추얼 전시 감상하기, 예술 아티클 읽기, 문화예술 영상 보기 등 + 5. HEALTH + - 정의: 신체적, 정신적 건강을 개선하고 유지하기 위한 활동, 스포츠 중심 + - 예시: 스트레칭하기, 명상하기, 근력운동하기 등 + --- + 출력 형식: + 원하는 활동 타입 == OFFLINE, ONLINE_AND_OFFLINE: + - title: [활동 제목 또는 추천장소] + - content: [활동 부제목] + - keyword: [활동 키워드] + - placeName: 사용자 위치에 따른 추천 장소 + --- + 예시 입력과 출력: + 예시 (활동 타입 == OFFLINE || 활동 타입 == ONLINE_AND_OFFLINE) + 입력: + 자투리 시간: 20분 + 선호 활동 타입: OFFLINE + 활동 키워드: ENTERTAINMENT, CULTURE_ARTS + 장소: 서울시 강남구 + + 출력: + title: 서울도서관에서 인사이트 가득한 책 읽기 + placeName: 서울도서관 + content: 독서는 마음의 양식! + keyword: SELF_DEVELOPMENT + + 주의사항: + - 요청에서 오는 활동 키워드와 응답의 keyword를 매칭해서 알려줘. 요청 이외 키워드는 절대 넣지마 + - title, content, keyword, placeName 구조 이외는 아무런 문장이나 미사여구도 붙이지마 + - 총 5개 추천해줘 + """; +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/response/ClovaResponse.java b/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/response/ClovaResponse.java new file mode 100644 index 000000000..924c7adea --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/clova/dto/response/ClovaResponse.java @@ -0,0 +1,23 @@ +package spring.backend.recommendation.infrastructure.clova.dto.response; + + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ClovaResponse { + private Result result; + + @Getter + @NoArgsConstructor + public static class Result { + private Message message; + } + + @Getter + @NoArgsConstructor + public static class Message { + private String content; + } +} \ No newline at end of file diff --git a/src/main/java/spring/backend/recommendation/infrastructure/clova/exception/ClovaErrorCode.java b/src/main/java/spring/backend/recommendation/infrastructure/clova/exception/ClovaErrorCode.java new file mode 100644 index 000000000..bce13115e --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/clova/exception/ClovaErrorCode.java @@ -0,0 +1,27 @@ +package spring.backend.recommendation.infrastructure.clova.exception; + + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum ClovaErrorCode implements BaseErrorCode { + + NO_RESPONSE_FROM_CLOVA(HttpStatus.INTERNAL_SERVER_ERROR, "클로바 서버로부터 응답이 없습니다."), + NULL_RESPONSE_FROM_CLOVA(HttpStatus.INTERNAL_SERVER_ERROR, "클로바 서버로부터 NULL값을 받았습니다."), + OFFLINE_TYPE_CONTAIN_SOCIAL(HttpStatus.BAD_REQUEST, "선호하는 활동 타입이 OFFLINE인 경우, SOCIAL(소셜) 키워드를 사용할 수 없습니다."), + INVALID_KEYWORD_IN_RECOMMENDATIONS(HttpStatus.BAD_REQUEST, "추천 활동의 키워드가 올바르지 않습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/dto/Message.java b/src/main/java/spring/backend/recommendation/infrastructure/dto/Message.java new file mode 100644 index 000000000..fc7ad9e2f --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/dto/Message.java @@ -0,0 +1,73 @@ +package spring.backend.recommendation.infrastructure.dto; + + +import lombok.Builder; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import static spring.backend.activity.domain.value.Type.OFFLINE; +import static spring.backend.activity.domain.value.Type.ONLINE_AND_OFFLINE; +import static spring.backend.recommendation.infrastructure.dto.Role.SYSTEM; +import static spring.backend.recommendation.infrastructure.dto.Role.USER; + +@Builder +public record Message(String role, String content) { + public static Message createSystem(String prompt) { + return Message.builder() + .role(SYSTEM.getDescription()) + .content(prompt) + .build(); + } + + public static Message createUserMessage(AIRecommendationRequest aiRecommendationRequest) { + return Message.builder() + .role(USER.getDescription()) + .content(createContent(aiRecommendationRequest)) + .build(); + } + + private static String createContent(AIRecommendationRequest aiRecommendationRequest) { + int spareTime = aiRecommendationRequest.spareTime(); + Type activityType = aiRecommendationRequest.activityType(); + String keywords = parseKeywords(aiRecommendationRequest.keywords()); + String location = aiRecommendationRequest.location(); + + if (isActivityTypeOfflineOrOnlineAndOffline(activityType, location)) { + return createContentForActivityTypeOfflineOrOnlineAndOffline(spareTime, activityType, keywords, location); + } else { + return createContentForActivityTypeOnline(spareTime, activityType, keywords); + } + } + + private static String createContentForActivityTypeOnline(int spareTime, Type activityType, String keywords) { + return String.format("\"자투리 시간: %d분\n선호 활동 타입: %s\n활동 키워드: %s\n\n 5가지 활동 추천해줘\n\n", spareTime, activityType, keywords); + } + + private static String createContentForActivityTypeOfflineOrOnlineAndOffline(int spareTime, Type activityType, String keywords, String location) { + return String.format("자투리 시간: %d분\n선호활동: %s\n활동 키워드: %s\n위치: %s\n\n 5가지 활동 추천해줘\n\n", spareTime, activityType, keywords, location); + } + + private static boolean isActivityTypeOfflineOrOnlineAndOffline(Type activityType, String location) { + if (activityType.equals(OFFLINE) && location == null || activityType.equals(ONLINE_AND_OFFLINE) && location == null) { + throw ActivityErrorCode.NOT_EXIST_LOCATION_WHEN_OFFLINE.toException(); + } + return activityType.equals(OFFLINE) || activityType.equals(ONLINE_AND_OFFLINE); + } + + private static String parseKeywords(Keyword.Category[] keywords) { + if (keywords.length == 0) { + return Arrays.stream(Keyword.Category.values()) + .map(Keyword.Category::getDescription) + .collect(Collectors.joining(", ")); + } else { + return Arrays.stream(keywords) + .map(Keyword.Category::getDescription) + .collect(Collectors.joining(", ")); + } + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/dto/Role.java b/src/main/java/spring/backend/recommendation/infrastructure/dto/Role.java new file mode 100644 index 000000000..b53834a8d --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/dto/Role.java @@ -0,0 +1,11 @@ +package spring.backend.recommendation.infrastructure.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum Role { + SYSTEM("system"), USER("user"); + private final String description; +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/link/LinkWebClient.java b/src/main/java/spring/backend/recommendation/infrastructure/link/LinkWebClient.java new file mode 100644 index 000000000..0d343e758 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/link/LinkWebClient.java @@ -0,0 +1,6 @@ +package spring.backend.recommendation.infrastructure.link; + +public interface LinkWebClient { + + T search(String query); +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/link/youtube/YoutubeWebClient.java b/src/main/java/spring/backend/recommendation/infrastructure/link/youtube/YoutubeWebClient.java new file mode 100644 index 000000000..600ea51c3 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/link/youtube/YoutubeWebClient.java @@ -0,0 +1,62 @@ +package spring.backend.recommendation.infrastructure.link.youtube; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; +import org.springframework.web.util.UriComponentsBuilder; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.recommendation.infrastructure.link.LinkWebClient; +import spring.backend.recommendation.infrastructure.link.youtube.dto.response.YoutubeResponse; + +import java.net.URI; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class YoutubeWebClient implements LinkWebClient { + + @Value("${youtube.api-key}") + private String apiKey; + + @Value("${youtube.search-url}") + private String searchUrl; + + @Override + public YoutubeResponse search(String query) { + try { + return WebClient.create() + .get() + .uri(buildSearchUrl(query)) + .retrieve() + .bodyToMono(YoutubeResponse.class) + .block(); + } catch (WebClientException e) { + log.error("WebClient 에러 발생 - 에러 메시지: {}", e.getMessage(), e); + throw GlobalErrorCode.WEB_CLIENT_ERROR.toException(); + } catch (Exception e) { + log.error("알 수 없는 내부 오류 발생 - 에러 메시지: {}", e.getMessage(), e); + throw GlobalErrorCode.INTERNAL_ERROR.toException(); + } + } + + private URI buildSearchUrl(String query) { + return UriComponentsBuilder.fromUriString(searchUrl) + .queryParams(createSearchRequestParams(query)) + .build() + .toUri(); + } + + private MultiValueMap createSearchRequestParams(String query) { + final String part = "snippet"; + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("key", apiKey); + params.add("part", part); + params.add("q", query); + return params; + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/link/youtube/dto/response/YoutubeResponse.java b/src/main/java/spring/backend/recommendation/infrastructure/link/youtube/dto/response/YoutubeResponse.java new file mode 100644 index 000000000..1d5ffde23 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/link/youtube/dto/response/YoutubeResponse.java @@ -0,0 +1,55 @@ +package spring.backend.recommendation.infrastructure.link.youtube.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record YoutubeResponse( + String kind, + String etag, + String nextPageToken, + String regionCode, + PageInfo pageInfo, + List items +) { + public record PageInfo( + int totalResults, + int resultsPerPage + ) {} + + public record YoutubeSearchItem( + String kind, + String etag, + Id id, + Snippet snippet + ) { + public record Id( + String kind, + String videoId, + String channelId, + String playlistId + ) {} + + public record Snippet( + LocalDateTime publishedAt, + String channelId, + String title, + String description, + Thumbnails thumbnails, + String channelTitle, + String liveBroadcastContent, + LocalDateTime publishTime + ) { + public record Thumbnails( + Thumbnail defaultThumbnail, + Thumbnail medium, + Thumbnail high + ) { + public record Thumbnail( + String url, + int width, + int height + ) {} + } + } + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/application/KakaoPlaceInfoProvider.java b/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/application/KakaoPlaceInfoProvider.java new file mode 100644 index 000000000..061c642e9 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/application/KakaoPlaceInfoProvider.java @@ -0,0 +1,55 @@ +package spring.backend.recommendation.infrastructure.map.kakao.application; + +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import spring.backend.recommendation.application.PlaceInfoProvider; +import spring.backend.recommendation.infrastructure.map.kakao.dto.response.KakaoMapResponse; +import spring.backend.recommendation.infrastructure.map.kakao.exception.KakaoMapErrorCode; + +@Component +@Log4j2 +public class KakaoPlaceInfoProvider implements PlaceInfoProvider { + + private final String restApiKey; + private final String searchPath; + private static final String QUERY = "query"; + private static final String AUTHORIZATION = "Authorization"; + private static final String KAKAO_AK = "KakaoAK "; + + private final WebClient webClient; + + public KakaoPlaceInfoProvider( + @Value("${kakao.rest-api-key}") String restApiKey, + @Value("${kakao.base-url}") String baseUrl, + @Value("${kakao.search-path}") String searchPath + + ) { + this.restApiKey = restApiKey; + this.searchPath = searchPath; + this.webClient = WebClient.create(baseUrl); + } + + @Override + public KakaoMapResponse search(String query) { + try { + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path(searchPath) + .queryParam(QUERY, query) + .build()) + .header(AUTHORIZATION, KAKAO_AK + restApiKey) + .retrieve() + .bodyToMono(KakaoMapResponse.class) + .block(); + } catch (WebClientResponseException e) { + log.error("카카오 지도 API 응답에 오류가 발생했습니다. 상태 코드: {} , 응답 본문: {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw KakaoMapErrorCode.RESPONSE_ERROR.toException(); + } catch (Exception e) { + log.error("장소 검색 중 예상치 못한 오류가 발생했습니다.", e); + throw KakaoMapErrorCode.UNKNOWN_ERROR.toException(); + } + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/dto/response/KakaoMapResponse.java b/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/dto/response/KakaoMapResponse.java new file mode 100644 index 000000000..3d597e31b --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/dto/response/KakaoMapResponse.java @@ -0,0 +1,51 @@ +package spring.backend.recommendation.infrastructure.map.kakao.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record KakaoMapResponse( + List documents, + Meta meta +) { + public record Document( + @JsonProperty("address_name") + String addressName, + @JsonProperty("category_group_code") + String categoryGroupCode, + @JsonProperty("category_group_name") + String categoryGroupName, + @JsonProperty("category_name") + String categoryName, + String distance, + String id, + String phone, + @JsonProperty("place_name") + String placeName, + @JsonProperty("place_url") + String placeUrl, + @JsonProperty("road_address_name") + String roadAddressName, + String x, + String y + ) { + } + + public record Meta( + @JsonProperty("is_end") + boolean isEnd, + @JsonProperty("pageable_count") + int pageableCount, + @JsonProperty("total_count") + int totalCount, + SameName sameName + ) { + public record SameName( + List region, + List keyword, + @JsonProperty("selected_region") + List selectedRegion + ) { + } + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/exception/KakaoMapErrorCode.java b/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/exception/KakaoMapErrorCode.java new file mode 100644 index 000000000..ba847cdea --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/map/kakao/exception/KakaoMapErrorCode.java @@ -0,0 +1,23 @@ +package spring.backend.recommendation.infrastructure.map.kakao.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum KakaoMapErrorCode implements BaseErrorCode { + RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 지도 API 응답에 오류가 발생했습니다."), + UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 지도 API 요청 중 알 수 없는 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/map/naver/application/NaverPlaceInfoProvider.java b/src/main/java/spring/backend/recommendation/infrastructure/map/naver/application/NaverPlaceInfoProvider.java new file mode 100644 index 000000000..b6786c34e --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/map/naver/application/NaverPlaceInfoProvider.java @@ -0,0 +1,119 @@ +package spring.backend.recommendation.infrastructure.map.naver.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import spring.backend.recommendation.application.PlaceInfoProvider; +import spring.backend.recommendation.infrastructure.map.naver.dto.response.NaverMapResponse; +import spring.backend.recommendation.infrastructure.map.naver.exception.NaverMapErrorCode; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +@Component +@Log4j2 +@RequiredArgsConstructor +public class NaverPlaceInfoProvider implements PlaceInfoProvider { + @Value("${naver.client-id}") + private String clientId; + + @Value("${naver.client-secret}") + private String clientSecret; + + @Value("${naver.map.base-uri}") + private String baseUri; + + private final ObjectMapper objectMapper; + + @Override + public NaverMapResponse search(String query) { + String encodedQuery = encodeSearchQuery(query); + String apiUrl = baseUri + "?query=" + encodedQuery; + Map requestHeaders = createHeaders(); + + String responseBody = fetchResponse(apiUrl, requestHeaders); + return parseResponse(responseBody); + } + + private Map createHeaders() { + Map headers = new HashMap<>(); + headers.put("X-Naver-Client-Id", clientId); + headers.put("X-Naver-Client-Secret", clientSecret); + return headers; + } + + private String encodeSearchQuery(String query) { + return URLEncoder.encode(query, StandardCharsets.UTF_8); + } + + private String fetchResponse(String apiUrl, Map requestHeaders) { + HttpURLConnection connection = createConnection(apiUrl); + try { + connection.setRequestMethod("GET"); + requestHeaders.forEach(connection::setRequestProperty); + + int responseCode = connection.getResponseCode(); + return responseCode == HttpURLConnection.HTTP_OK + ? readStream(connection.getInputStream()) + : readStream(connection.getErrorStream()); + } catch (IOException e) { + log.error( + "API 요청과 응답이 실패했습니다. - 에러 메시지: {}", + e.getMessage(), + e + ); + throw NaverMapErrorCode.API_REQUEST_FAILED.toException(); + } finally { + connection.disconnect(); + } + } + + private HttpURLConnection createConnection(String apiUrl) { + try { + URL url = new URL(apiUrl); + return (HttpURLConnection) url.openConnection(); + } catch (IOException e) { + log.error( + "API 연결에 실패했습니다. - 에러 메시지: {}", + e.getMessage(), + e + ); + throw NaverMapErrorCode.FAILED_TO_CONNECT_API.toException(); + } + } + + private String readStream(InputStream stream) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + return response.toString(); + } catch (IOException e) { + log.error( + "API 응답을 읽는데 실패했습니다. - 에러 메시지: {}", + e.getMessage(), + e + ); + throw NaverMapErrorCode.FAILED_TO_READ_RESPONSE.toException(); + } + } + + private NaverMapResponse parseResponse(String responseBody) { + try { + return objectMapper.readValue(responseBody, NaverMapResponse.class); + } catch (IOException e) { + log.error("응답을 파싱하는데 실패했습니다. - 에러 메시지: {}", e.getMessage(), e); + throw NaverMapErrorCode.FAILED_TO_PARSE_RESPONSE.toException(); + } + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/map/naver/dto/response/NaverMapResponse.java b/src/main/java/spring/backend/recommendation/infrastructure/map/naver/dto/response/NaverMapResponse.java new file mode 100644 index 000000000..4b1de5748 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/map/naver/dto/response/NaverMapResponse.java @@ -0,0 +1,24 @@ +package spring.backend.recommendation.infrastructure.map.naver.dto.response; + +import java.util.List; + +public record NaverMapResponse( + String lastBuildDate, + int total, + int start, + int display, + List items +) { + public record NaverMapSearchItem( + String title, + String link, + String category, + String description, + String telephone, + String address, + String roadAddress, + double mapx, + double mapy + ) { + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/map/naver/exception/NaverMapErrorCode.java b/src/main/java/spring/backend/recommendation/infrastructure/map/naver/exception/NaverMapErrorCode.java new file mode 100644 index 000000000..927fc00e9 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/map/naver/exception/NaverMapErrorCode.java @@ -0,0 +1,25 @@ +package spring.backend.recommendation.infrastructure.map.naver.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum NaverMapErrorCode implements BaseErrorCode { + API_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "API 요청과 응답이 실패했습니다."), + FAILED_TO_CONNECT_API(HttpStatus.INTERNAL_SERVER_ERROR, "API 연결에 실패했습니다."), + FAILED_TO_READ_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "API 응답을 읽는데 실패했습니다."), + FAILED_TO_PARSE_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "응답을 파싱하는데 실패했습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/openai/OpenAIRecommendationProvider.java b/src/main/java/spring/backend/recommendation/infrastructure/openai/OpenAIRecommendationProvider.java new file mode 100644 index 000000000..b533fd375 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/openai/OpenAIRecommendationProvider.java @@ -0,0 +1,68 @@ +package spring.backend.recommendation.infrastructure.openai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; +import reactor.core.publisher.Mono; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.recommendation.application.RecommendationProvider; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; +import spring.backend.recommendation.infrastructure.dto.Message; +import spring.backend.recommendation.infrastructure.openai.dto.request.OpenAIPrompt; +import spring.backend.recommendation.infrastructure.openai.dto.response.OpenAIResponse; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class OpenAIRecommendationProvider implements RecommendationProvider> { + + @Value("${openai.model}") + private String model; + + @Value("${openai.secret-key}") + private String secretKey; + + @Value("${openai.chat-completions-url}") + private String chatCompletionsUrl; + + public Mono getRecommendations(AIRecommendationRequest request) { + return WebClient.create() + .post() + .uri(chatCompletionsUrl) + .headers(header -> { + header.setContentType(MediaType.APPLICATION_JSON); + header.setBearerAuth(secretKey); + }) + .bodyValue(createRecommendationRequestBody(request)) + .retrieve() + .bodyToMono(OpenAIResponse.class) + .onErrorResume(WebClientException.class, e -> { + log.error("WebClient 에러 발생 - 에러 메시지: {}", e.getMessage(), e); + return Mono.error(GlobalErrorCode.WEB_CLIENT_ERROR.toException()); + }) + .onErrorResume(Exception.class, e -> { + log.error("알 수 없는 내부 오류 발생 - 에러 메시지: {}", e.getMessage(), e); + return Mono.error(GlobalErrorCode.INTERNAL_ERROR.toException()); + }); + } + + private Map createRecommendationRequestBody(AIRecommendationRequest request) { + Map requestBody = new HashMap<>(); + requestBody.put("model", model); + + List messages = new ArrayList<>(); + messages.add(Message.createSystem(OpenAIPrompt.DEFAULT_SYSTEM_PROMPT)); + messages.add(Message.createUserMessage(request)); + requestBody.put("messages", messages); + return requestBody; + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/openai/dto/request/OpenAIPrompt.java b/src/main/java/spring/backend/recommendation/infrastructure/openai/dto/request/OpenAIPrompt.java new file mode 100644 index 000000000..36f091954 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/openai/dto/request/OpenAIPrompt.java @@ -0,0 +1,94 @@ +package spring.backend.recommendation.infrastructure.openai.dto.request; + +public class OpenAIPrompt { + public static final String DEFAULT_SYSTEM_PROMPT = """ + 역할: + 너는 사용자가 입력한 정보를 바탕으로 자투리 시간에 할 수 있는 활동을 추천하는 AI 봇이야. 사용자가 제공하는 정보를 기반으로 적합한 활동을 5가지 추천해줘. 추천은 구체적이고 특정한걸로 되어야하며 추상적이거나 뻔한 활동은 배제해줘. 또한 한국을 기준으로 없는 사이트는 추천하지 말아줘. + --- + 입력 정보: + 1. 자투리 시간: 사용자가 활용할 수 있는 시간 (예: 10분, 60분 등). + 2. 활동 타입: + - ONLINE, ONLINE_AND_OFFLINE: 온라인 활동 추천 + 3. 활동 키워드: 사용자가 관심 있는 주제 (예: 휴식, 자기개발, 문화/예술 등). + 4. 플랫폼: 해당 활동에 사용할 수 있는 온라인 플랫폼 + 5. 링크: 플랫폼의 도메인 주소 + --- + 추천 기준: + 1. 활동 타입이 ONLINE, ONLINE_AND_OFFLINE일 경우: + - 입력된 활동 키워드와 시간을 고려하여 다양한 온라인 활동을 추천. + - 추천되는 활동의 플랫폼은 한국 사이트를 우선순위로 추천. + - 지금 추천에서 콘텐츠라면 특정한 콘텐츠를 지정해서 알려줘. 예를 들어 넷플릭스 다큐라면, 어떤 다큐를 말하는건지, 유튜브 명상 콘텐츠라면 어떤 채널의 콘텐츠인지 등 + --- + 활동 키워드별 정의와 예시: + 1. 자기개발 + - 정의: 시사상식, 지식, 교양과 관련된 활동으로, 개인의 성장과 발전을 위한 것 + - 예시: 뉴스 기사 읽기, 온라인 강연 보기, 팟캐스트 듣기, 언어 공부하기 등 + 2. 엔터테인먼트 + - 정의: 즐거움과 오락을 목적으로 한 활동, 순간의 재미와 유희를 위한 것 + - 예시: 유튜브 콘텐츠 시청하기, 음악듣기, OTT 시청하기 등 + 3. 휴식 + - 정의: 신체적, 정신적 피로 회복과 재충전을 위한 정적인 활동 + - 예시: 명상하기, 짧은 글쓰기, ASMR 듣기 등 + 4. 문화/예술 + - 정의: 예술적, 문화적 경험과 감상을 통해 영감과 인사이트를 얻는 활동 + - 예시: 버추얼 전시 감상하기, 예술 아티클 읽기, 문화예술 영상 보기 등 + 5. 건강 + - 정의: 신체적, 정신적 건강을 개선하고 유지하기 위한 활동, 스포츠 중심 + - 예시: 스트레칭하기, 명상하기, 근력운동하기 등 + 6. 소셜 + - 정의: 사회적 관계 형성과 유지를 위한 활동, 사람들과의 교류와 유대감 + - 예시: SNS 활동하기, 사람들과 소식 공유하기, 사람들에게 연락하기 등 + --- + 출력 형식: + 원하는 활동 타입 == ONLINE, ONLINE_AND_OFFLINE: + - title: [활동 제목 또는 추천장소] + - content: [활동 부제목] + - keyword: [활동 키워드] + - platform: [활동에 사용할 수 있는 온라인 또는 오프라인 플랫폼] + - url: 플랫폼의 도메인 주소 + --- + 예시 입력과 출력: + 예시 (활동 타입 == ONLINE || 활동 타입 == ONLINE_AND_OFFLINE) + 입력: + 자투리 시간: 20분 + 선호 활동 타입: ONLINE + 활동 키워드: 자기개발, 엔터테인먼트, 소셜, 휴식 + + 출력: + title: Daniel Hallak의 TED 강연 듣기 + content: 무궁무진한 세상의 이야기들! + keyword: 자기개발 + platform: TED + url: https://www.ted.com/ + + title: 인사이트 가득한 트렌드 레터 읽기 + content: 요즘 트렌드는 뭐지? + keyword: 자기개발 + platform: 캐릿 + url: https://www.careet.net/ + + title: 유튜브에서 ‘지미 팰런 쇼’ 하이라이트 보기 + content: 미국 코미디 쇼 몰아보기! + keyword: 엔터테인먼트 + platform: Youtube + url: https://www.youtube.com/ + + title: 가장 좋았던 릴스 인스타그램 스토리에 공유하기 + content: 이번주 나의 픽! + keyword: 소셜 + platform: Instagram + url: https://www.instagram.com/ + + title: 유튜브에서 마음을 편안하게 해주는 ASMR 들으며 명상하기 + content: 심신의 안정엔 명상! + keyword: 휴식 + platform: Youtube + url: https://www.youtube.com/ + + 주의사항: + - 활동 타입이 ONLINE이면, 활동을 5개만 추천해줘 + - 활동 타입이 ONLINE_AND_OFFLINE이면, 활동을 2개만 추천해줘 + - 요청으로 location이 있어도 무시하고 추천해줘 + - title, content, keyword, platform, url 구조 이외는 아무런 문장이나 미사여구도 붙이지마 + """; +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/openai/dto/response/OpenAIResponse.java b/src/main/java/spring/backend/recommendation/infrastructure/openai/dto/response/OpenAIResponse.java new file mode 100644 index 000000000..a85592455 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/openai/dto/response/OpenAIResponse.java @@ -0,0 +1,49 @@ +package spring.backend.recommendation.infrastructure.openai.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import spring.backend.recommendation.infrastructure.dto.Message; + +import java.util.List; + +public record OpenAIResponse( + String id, + String object, + int created, + String model, + @JsonProperty("system_fingerprint") + String systemFingerprint, + List choices, + Usage usage +) { + + public record Choice( + int index, + Message message, + Boolean logprobs, + @JsonProperty("finish_reason") + String finishReason + ) { + } + + public record Usage( + @JsonProperty("prompt_tokens") + int promptTokens, + @JsonProperty("completion_tokens") + int completionTokens, + @JsonProperty("total_tokens") + int totalTokens, + @JsonProperty("completion_tokens_details") + CompletionTokensDetails completionTokensDetails + ) { + } + + public record CompletionTokensDetails( + @JsonProperty("reasoning_tokens") + int reasoningTokens, + @JsonProperty("accepted_prediction_tokens") + int acceptedPredictionTokens, + @JsonProperty("rejected_prediction_tokens") + int rejectedPredictionTokens + ) { + } +} diff --git a/src/main/java/spring/backend/recommendation/infrastructure/openai/exception/OpenAIErrorCode.java b/src/main/java/spring/backend/recommendation/infrastructure/openai/exception/OpenAIErrorCode.java new file mode 100644 index 000000000..fed314d68 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/infrastructure/openai/exception/OpenAIErrorCode.java @@ -0,0 +1,25 @@ +package spring.backend.recommendation.infrastructure.openai.exception; + + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import spring.backend.core.exception.DomainException; +import spring.backend.core.exception.error.BaseErrorCode; + +@Getter +@RequiredArgsConstructor +public enum OpenAIErrorCode implements BaseErrorCode { + + NOT_FOUND_RECOMMENDATION(HttpStatus.NOT_FOUND, "OpenAI에서의 추천이 존재하지 않습니다."), + FAILED_RECOMMENDATION_GENERATION(HttpStatus.INTERNAL_SERVER_ERROR, "추천 생성 중 에러가 발생하였습니다."); + + private final HttpStatus httpStatus; + + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } +} diff --git a/src/main/java/spring/backend/recommendation/presentation/GetRecommendationsController.java b/src/main/java/spring/backend/recommendation/presentation/GetRecommendationsController.java new file mode 100644 index 000000000..71a35f064 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/presentation/GetRecommendationsController.java @@ -0,0 +1,49 @@ +package spring.backend.recommendation.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import spring.backend.core.configuration.argumentresolver.AuthorizedMember; +import spring.backend.core.configuration.interceptor.Authorization; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; +import spring.backend.recommendation.application.GetRecommendationsFromClovaService; +import spring.backend.recommendation.application.GetRecommendationsFromOpenAIService; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; +import spring.backend.recommendation.presentation.dto.response.ClovaRecommendationResponse; +import spring.backend.recommendation.presentation.dto.response.OpenAIRecommendationResponse; +import spring.backend.recommendation.presentation.dto.response.RecommendationResponse; +import spring.backend.recommendation.presentation.swagger.GetRecommendationsSwagger; + +import java.util.ArrayList; +import java.util.List; + + +@RestController +@RequiredArgsConstructor +public class GetRecommendationsController implements GetRecommendationsSwagger { + + private final GetRecommendationsFromClovaService getRecommendationsFromClovaService; + + private final GetRecommendationsFromOpenAIService getRecommendationsFromOpenAIService; + + @Authorization + @PostMapping("/v1/recommendations") + public ResponseEntity> getRecommendations(@AuthorizedMember Member member, @Valid @RequestBody AIRecommendationRequest request) { + List offlineRecommendations = new ArrayList<>(); + List onlineRecommendations = new ArrayList<>(); + + if (request.activityType().includesOffline()) { + offlineRecommendations = getRecommendationsFromClovaService.getRecommendationsFromClova(request); + } + if (request.activityType().includesOnline()) { + onlineRecommendations = getRecommendationsFromOpenAIService.getRecommendationsFromOpenAI(request); + } + + RecommendationResponse recommendationResponse = RecommendationResponse.of(offlineRecommendations, onlineRecommendations); + return ResponseEntity.ok(new RestResponse<>(recommendationResponse)); + } +} diff --git a/src/main/java/spring/backend/recommendation/presentation/dto/request/AIRecommendationRequest.java b/src/main/java/spring/backend/recommendation/presentation/dto/request/AIRecommendationRequest.java new file mode 100644 index 000000000..8891a12be --- /dev/null +++ b/src/main/java/spring/backend/recommendation/presentation/dto/request/AIRecommendationRequest.java @@ -0,0 +1,28 @@ +package spring.backend.recommendation.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; + +public record AIRecommendationRequest( + @NotNull(message = "자투리 시간은 필수 입력 항목입니다.") + @Min(value = 10, message = "자투리 시간은 10부터 300 사이의 숫자로 입력해주세요.") + @Max(value = 300, message = "자투리 시간은 10부터 300 사이의 숫자로 입력해주세요.") + @Schema(description = "자투리 시간", example = "30") + Integer spareTime, + + @NotNull(message = "활동 유형은 필수 입력 항목입니다.") + @Schema(description = "활동 타입(ONLINE, OFFLINE, ONLINE_AND_OFFLINE 중 하나를 선택합니다.)", example = "OFFLINE") + Type activityType, + + @NotNull(message = "키워드는 필수 입력 항목입니다.") + @Schema(description = "활동 키워드", example = "[\"RELAXATION\",\"ENTERTAINMENT\"]") + Keyword.Category[] keywords, + + @Schema(description = "위치(activityType이 OFFLINE, ONLINE_AND_OFFLINE인 경우에만 필요합니다.)", example = "서울시 마포구 공덕동") + String location +) { +} diff --git a/src/main/java/spring/backend/recommendation/presentation/dto/response/ClovaRecommendationResponse.java b/src/main/java/spring/backend/recommendation/presentation/dto/response/ClovaRecommendationResponse.java new file mode 100644 index 000000000..1c016a691 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/presentation/dto/response/ClovaRecommendationResponse.java @@ -0,0 +1,27 @@ +package spring.backend.recommendation.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import spring.backend.activity.domain.value.Keyword; + +@Getter +@AllArgsConstructor +public class ClovaRecommendationResponse { + @Schema(description = "추천 순서") + private int order; + @Schema(description = "추천 제목") + private String title; + @Schema(description = "장소 이름") + private String placeName; + @Schema(description = "장소의 x 좌표") + private String mapx; + @Schema(description = "장소의 y 좌표") + private String mapy; + @Schema(description = "장소의 카카오맵 url (카카오맵만 제공)") + private String placeUrl; + @Schema(description = "추천 부제목") + private String content; + @Schema(description = "추천 키워드") + private Keyword keyword; +} diff --git a/src/main/java/spring/backend/recommendation/presentation/dto/response/OpenAIRecommendationResponse.java b/src/main/java/spring/backend/recommendation/presentation/dto/response/OpenAIRecommendationResponse.java new file mode 100644 index 000000000..1bd08cb99 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/presentation/dto/response/OpenAIRecommendationResponse.java @@ -0,0 +1,29 @@ +package spring.backend.recommendation.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import spring.backend.activity.domain.value.Keyword; + +public record OpenAIRecommendationResponse( + + @Schema(description = "추천 결과 순서", example = "1") + int order, + + @Schema(description = "직접적 추천 활동", example = "마음의 편안을 가져다주는 명상 음악 20분 듣기") + String title, + + @Schema(description = "꾸밈글", example = "휴식에는 역시 명상이 최고!") + String content, + + @Schema(description = "활동 키워드", example = "{\n" + + " \"category\": \"SELF_DEVELOPMENT\",\n" + + " \"image\": \"images/self_development.png\"\n" + + " }") + Keyword keyword, + + @Schema(description = "외부 링크", example = "https://www.youtube.com") + String url +) { + public static OpenAIRecommendationResponse of(int order, String title, String content, Keyword keyword, String url) { + return new OpenAIRecommendationResponse(order, title, content, keyword, url); + } +} diff --git a/src/main/java/spring/backend/recommendation/presentation/dto/response/RecommendationResponse.java b/src/main/java/spring/backend/recommendation/presentation/dto/response/RecommendationResponse.java new file mode 100644 index 000000000..c61935a21 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/presentation/dto/response/RecommendationResponse.java @@ -0,0 +1,18 @@ +package spring.backend.recommendation.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record RecommendationResponse( + + @Schema(description = "[OFFLINE, ONLINE_AND_OFFLINE] 오프라인 추천 응답") + List offlineRecommendations, + + @Schema(description = "[ONLINE, ONLINE_AND_OFFLINE] 온라인 추천 응답") + List onlineRecommendations +) { + public static RecommendationResponse of(List offlineRecommendations, List onlineRecommendations) { + return new RecommendationResponse(offlineRecommendations, onlineRecommendations); + } +} diff --git a/src/main/java/spring/backend/recommendation/presentation/swagger/GetRecommendationsSwagger.java b/src/main/java/spring/backend/recommendation/presentation/swagger/GetRecommendationsSwagger.java new file mode 100644 index 000000000..0e2547d69 --- /dev/null +++ b/src/main/java/spring/backend/recommendation/presentation/swagger/GetRecommendationsSwagger.java @@ -0,0 +1,26 @@ +package spring.backend.recommendation.presentation.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import spring.backend.core.configuration.swagger.ApiErrorCode; +import spring.backend.core.exception.error.GlobalErrorCode; +import spring.backend.core.presentation.RestResponse; +import spring.backend.member.domain.entity.Member; +import spring.backend.recommendation.presentation.dto.request.AIRecommendationRequest; +import spring.backend.recommendation.presentation.dto.response.RecommendationResponse; +import spring.backend.recommendation.infrastructure.clova.exception.ClovaErrorCode; +import spring.backend.recommendation.infrastructure.openai.exception.OpenAIErrorCode; + +@Tag(name = "Recommendation", description = "추천") +public interface GetRecommendationsSwagger { + + @Operation( + summary = "사용자 추천 요청 API", + description = "사용자가 활동 추천을 요청합니다.", + operationId = "/v1/recommendations" + ) + @ApiErrorCode({GlobalErrorCode.class, ClovaErrorCode.class, OpenAIErrorCode.class}) + ResponseEntity> getRecommendations(@Parameter(hidden = true) Member member, AIRecommendationRequest request); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 3ca17a4e3..000000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=backend diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..a5c1568db --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + application: + name: backend diff --git a/src/main/resources/templates/mail.html b/src/main/resources/templates/mail.html new file mode 100644 index 000000000..e2c8cae3a --- /dev/null +++ b/src/main/resources/templates/mail.html @@ -0,0 +1,22 @@ + + + + + 조각조각 메일 + + + + + + +
+ + 조각조각 이미지 + +
+ + diff --git a/src/test/java/spring/backend/activity/application/QuickStartActivitySelectServiceTest.java b/src/test/java/spring/backend/activity/application/QuickStartActivitySelectServiceTest.java new file mode 100644 index 000000000..f665cd99a --- /dev/null +++ b/src/test/java/spring/backend/activity/application/QuickStartActivitySelectServiceTest.java @@ -0,0 +1,129 @@ +package spring.backend.activity.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.quickstart.domain.entity.QuickStart; +import spring.backend.activity.domain.repository.ActivityRepository; +import spring.backend.quickstart.domain.repository.QuickStartRepository; +import spring.backend.activity.domain.service.FinishActivityAutoService; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; +import spring.backend.activity.presentation.dto.request.QuickStartActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.QuickStartActivitySelectResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.quickstart.exception.QuickStartErrorCode; +import spring.backend.core.exception.DomainException; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.value.Role; + +import java.time.LocalTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class QuickStartActivitySelectServiceTest { + @InjectMocks + private QuickStartActivitySelectService quickStartActivitySelectService; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private QuickStartRepository quickStartRepository; + + @Mock + private FinishActivityAutoService finishActivityAutoService; + + private Member member; + private QuickStartActivitySelectRequest quickStartActivitySelectRequest; + private final Long quickStartId = 1L; + + @BeforeEach + public void setUp() { + member = Member.builder() + .role(Role.MEMBER) + .build(); + + Keyword keyword = Keyword.create(Keyword.Category.CULTURE_ART, "example-image1.png"); + + quickStartActivitySelectRequest = new QuickStartActivitySelectRequest( + Type.OFFLINE, + 150, + keyword, + "title", + "content", + "location" + ); + } + + @DisplayName("QuickStart가 존재하지 않는 경우 예외가 발생한다") + @Test + public void throwsExceptionWhenUserActivitySelectRequestIsNull() { + // given & when + when(quickStartRepository.findById(anyLong())).thenReturn(null); + DomainException ex = assertThrows(DomainException.class, () -> + quickStartActivitySelectService.quickStartUserActivitySelect(member, quickStartId, quickStartActivitySelectRequest) + ); + // then + assertEquals(QuickStartErrorCode.NOT_EXIST_QUICK_START.getMessage(), ex.getMessage()); + } + + @DisplayName("quickStartActivitySelectRequest가 null인 경우 예외를 반환한다.") + @Test + public void throwsExceptionWhenQuickStartActivitySelectRequestIsNull() { + QuickStart quickStart = QuickStart.create( + UUID.randomUUID(), + "name", + LocalTime.now(), + 150, + Type.ONLINE + ); + when(quickStartRepository.findById(quickStartId)).thenReturn(quickStart); + + // when + DomainException ex = assertThrows(DomainException.class, () -> + quickStartActivitySelectService.quickStartUserActivitySelect(member, quickStartId, null) + ); + + // then + assertEquals(ActivityErrorCode.NOT_EXIST_ACTIVITY_CONDITION.getMessage(), ex.getMessage()); + } + + @DisplayName("빠른시작 활동 선택에 문제가 없는 경우, 저장된 활동의 QuickStartActivitySelectResponse를 반환한다.") + @Test + public void returnSavedActivityIdWhenNothingWrong() { + // when + QuickStart quickStart = QuickStart.create( + UUID.randomUUID(), + "name", + LocalTime.now(), + 150, + Type.ONLINE + ); + when(quickStartRepository.findById(quickStartId)).thenReturn(quickStart); + Activity activity = Activity.create(member.getId(), quickStartId, quickStartActivitySelectRequest.spareTime(), quickStartActivitySelectRequest.type(), quickStartActivitySelectRequest.keyword(), quickStartActivitySelectRequest.title(), quickStartActivitySelectRequest.content(), quickStartActivitySelectRequest.location()); + when(activityRepository.save(any(Activity.class))).thenReturn(activity); + + // then + QuickStartActivitySelectResponse quickStartActivitySelectResponse = quickStartActivitySelectService.quickStartUserActivitySelect(member, quickStartId, quickStartActivitySelectRequest); + + // then + assertEquals(activity.getQuickStartId(), quickStartId); + assertEquals(activity.getId(), quickStartActivitySelectResponse.id()); + assertEquals(activity.getTitle(), quickStartActivitySelectResponse.title()); + assertEquals(activity.getKeyword(), quickStartActivitySelectResponse.keyword()); + verify(activityRepository).save(any(Activity.class)); + } +} diff --git a/src/test/java/spring/backend/activity/application/UserActivitySelectServiceTest.java b/src/test/java/spring/backend/activity/application/UserActivitySelectServiceTest.java new file mode 100644 index 000000000..f0a767fb4 --- /dev/null +++ b/src/test/java/spring/backend/activity/application/UserActivitySelectServiceTest.java @@ -0,0 +1,88 @@ +package spring.backend.activity.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.repository.ActivityRepository; +import spring.backend.activity.domain.service.FinishActivityAutoService; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; +import spring.backend.activity.presentation.dto.request.UserActivitySelectRequest; +import spring.backend.activity.presentation.dto.response.UserActivitySelectResponse; +import spring.backend.activity.exception.ActivityErrorCode; +import spring.backend.core.exception.DomainException; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.value.Role; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class UserActivitySelectServiceTest { + @InjectMocks + private UserActivitySelectService userActivitySelectService; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private FinishActivityAutoService finishActivityAutoService; + + private Member member; + private UserActivitySelectRequest userActivitySelectRequest; + + @BeforeEach + public void setUp() { + member = Member.builder() + .role(Role.MEMBER) + .build(); + + Keyword keyword = Keyword.create(Keyword.Category.CULTURE_ART, "example-image1.png"); + + + userActivitySelectRequest = new UserActivitySelectRequest( + Type.OFFLINE, + 150, + keyword, + "title", + "content", + "location" + ); + } + + @DisplayName("요청이 null인 경우 예외가 발생한다") + @Test + public void throwsExceptionWhenUserActivitySelectRequestIsNull() { + // when + DomainException ex = assertThrows(DomainException.class, () -> userActivitySelectService.userActivitySelect(member, null)); + + // then + assertEquals(ActivityErrorCode.NOT_EXIST_ACTIVITY_CONDITION.getMessage(), ex.getMessage()); + } + + @DisplayName("유효한 활동 선택인 경우 저장된 ID를 반환한다") + @Test + public void returnsSavedActivityIdWhenValidActivitySelection() { + // when + Activity activity = Activity.create(member.getId(), null, userActivitySelectRequest.spareTime(), userActivitySelectRequest.type(), userActivitySelectRequest.keyword(), userActivitySelectRequest.title(), userActivitySelectRequest.content(), userActivitySelectRequest.location()); + when(activityRepository.save(any(Activity.class))).thenReturn(activity); + + // then + UserActivitySelectResponse userActivitySelectResponse = userActivitySelectService.userActivitySelect(member, userActivitySelectRequest); + + // then + assertEquals(activity.getId(), userActivitySelectResponse.id()); + assertEquals(activity.getTitle(), userActivitySelectResponse.title()); + assertEquals(activity.getKeyword(), userActivitySelectResponse.keyword()); + verify(activityRepository).save(any(Activity.class)); + } + +} diff --git a/src/test/java/spring/backend/activity/domain/repository/ActivityRepositoryTest.java b/src/test/java/spring/backend/activity/domain/repository/ActivityRepositoryTest.java new file mode 100644 index 000000000..e1b5e21db --- /dev/null +++ b/src/test/java/spring/backend/activity/domain/repository/ActivityRepositoryTest.java @@ -0,0 +1,52 @@ +package spring.backend.activity.domain.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import spring.backend.activity.domain.entity.Activity; +import spring.backend.activity.domain.value.Keyword; +import spring.backend.activity.domain.value.Type; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@SpringBootTest +class ActivityRepositoryTest { + + @Autowired + private ActivityRepository activityRepository; + + private Activity activity; + + @BeforeEach + void setUp() { + activity = Activity.builder() + .memberId(UUID.randomUUID()) + .quickStartId(100L) + .spareTime(120) + .type(Type.ONLINE) + .keyword(Keyword.create(Keyword.Category.SELF_DEVELOPMENT, "test.url")) + .title("Test Activity") + .content("This is a test activity.") + .location("Test Location") + .finished(false) + .finishedAt(null) + .savedTime(30) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .deleted(false) + .build(); + } + + @Test + void testSaveAndFindActivity() { + Activity savedActivity = activityRepository.save(activity); + Activity foundActivity = activityRepository.findById(savedActivity.getId()); + + assertThat(foundActivity).isNotNull(); + assertThat(foundActivity.getKeyword()).isEqualTo(activity.getKeyword()); + } +} diff --git a/src/test/java/spring/backend/auth/application/OnboardingSignUpServiceTest.java b/src/test/java/spring/backend/auth/application/OnboardingSignUpServiceTest.java new file mode 100644 index 000000000..cd3145136 --- /dev/null +++ b/src/test/java/spring/backend/auth/application/OnboardingSignUpServiceTest.java @@ -0,0 +1,88 @@ +package spring.backend.auth.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import spring.backend.auth.presentation.dto.request.OnboardingSignUpRequest; +import spring.backend.auth.presentation.dto.response.OnboardingSignUpResponse; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.core.exception.DomainException; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.domain.value.Gender; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class OnboardingSignUpServiceTest { + + @InjectMocks + private OnboardingSignUpService onboardingSignUpService; + + @Mock + private MemberRepository memberRepository; + + private Member member; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + member = mock(Member.class); + } + + @DisplayName("회원가입 요청이 null인 경우 예외가 발생한다") + @Test + public void throwExceptionWhenRequestIsNull() { + // When & Then + Exception exception = assertThrows(DomainException.class, + () -> onboardingSignUpService.onboardingSignUp(member, null)); + assertEquals(AuthenticationErrorCode.NOT_EXIST_SIGN_UP_CONDITION.getMessage(), exception.getMessage()); + } + + @DisplayName("유효하지 않은 멤버 상태인 경우 예외가 발생한다") + @Test + public void throwExceptionWhenMemberIsInvalid() { + // Given + when(member.isMember()).thenReturn(true); + + // When & Then + Exception exception = assertThrows(DomainException.class, + () -> onboardingSignUpService.onboardingSignUp(member, new OnboardingSignUpRequest("조각조각", 2000, Gender.MALE, "http://test.jpg"))); + assertEquals(AuthenticationErrorCode.INVALID_MEMBER_SIGN_UP_CONDITION.getMessage(), exception.getMessage()); + } + + @DisplayName("출생년도가 유효하지 않은 경우 예외가 발생한다") + @Test + public void throwExceptionWhenBirthYearIsInvalid() { + // Given + OnboardingSignUpRequest request = new OnboardingSignUpRequest("조각조각", 1900, Gender.MALE, "http://test.jpg"); + + // When & Then + Exception exception = assertThrows(DomainException.class, + () -> onboardingSignUpService.onboardingSignUp(member, request)); + + assertEquals(AuthenticationErrorCode.INVALID_BIRTH_YEAR.getMessage(), exception.getMessage()); + } + + @DisplayName("유효한 회원가입 요청인 경우 회원 정보가 저장된다") + @Test + public void saveMemberWhenRequestIsValid() { + // Given + OnboardingSignUpRequest request = new OnboardingSignUpRequest("조각조각", 2001, Gender.MALE, "http://test.jpg"); + when(member.isMember()).thenReturn(false); + when(memberRepository.save(any(Member.class))).thenReturn(member); + + // When + OnboardingSignUpResponse onboardingSignUpResponse = onboardingSignUpService.onboardingSignUp(member, request); + + // Then + assertNotNull(onboardingSignUpResponse); + + verify(member).convertGuestToMember("조각조각", 2001, Gender.MALE, "http://test.jpg"); + verify(memberRepository).save(member); + } +} diff --git a/src/test/java/spring/backend/core/application/JwtServiceTest.java b/src/test/java/spring/backend/core/application/JwtServiceTest.java new file mode 100644 index 000000000..4e12021e3 --- /dev/null +++ b/src/test/java/spring/backend/core/application/JwtServiceTest.java @@ -0,0 +1,77 @@ +package spring.backend.core.application; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.core.exception.DomainException; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class JwtServiceTest { + + @Autowired + private JwtService jwtService; + + @Value("${jwt.secret}") + private String secret; + private String validJwt; + private String invalidJwt; + private String expiredJwt; + + @BeforeEach + void setUp() { + long expirationTime = 1; + Date expiryDate = Date.from(Instant.now().plus(expirationTime, ChronoUnit.DAYS)); + SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + + validJwt = Jwts.builder() + .claim("name", "test") + .issuedAt(new Date()) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + + invalidJwt = validJwt + "invalid"; + + expiryDate = Date.from(Instant.now().minus(expirationTime, ChronoUnit.DAYS)); + expiredJwt = Jwts.builder() + .claim("name", "test") + .issuedAt(new Date()) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + @DisplayName("유효한 JWT를 파싱하여 claim을 반환한다") + @Test + void getPayloadWithValidJwt() { + // when + Claims claims = jwtService.getPayload(validJwt); + + // then + assertThat(claims.get("name")).isEqualTo("test"); + } + + @DisplayName("만료된 토큰일 경우 예외를 발생시킨다") + @Test + void validateTokenExpirationWithExpiredJwt() { + // when & then + DomainException ex = assertThrows(DomainException.class, () -> jwtService.validateTokenExpiration(expiredJwt), "만료된 토큰입니다."); + assertThat(ex.getCode()).isEqualTo(AuthenticationErrorCode.EXPIRED_TOKEN.name()); + } +} diff --git a/src/test/java/spring/backend/core/application/RefreshTokenServiceTest.java b/src/test/java/spring/backend/core/application/RefreshTokenServiceTest.java new file mode 100644 index 000000000..b9a90979c --- /dev/null +++ b/src/test/java/spring/backend/core/application/RefreshTokenServiceTest.java @@ -0,0 +1,67 @@ +package spring.backend.core.application; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import spring.backend.auth.application.RefreshTokenService; +import spring.backend.auth.infrastructure.redis.repository.RefreshTokenRedisRepository; +import spring.backend.member.domain.entity.Member; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +public class RefreshTokenServiceTest { + @Autowired + private RefreshTokenService refreshTokenService; + + @Autowired + private RefreshTokenRedisRepository refreshTokenRedisRepository; + + @Autowired + private JwtService jwtService; + + private final UUID memberId = UUID.randomUUID(); + + private final Member member = Member.builder() + .id(memberId) + .email("test@test.com") + .build(); + + @Autowired + private RedisTemplate redisTemplate; + + @BeforeEach + public void checkRedisConnection() { + try { + redisTemplate.getConnectionFactory().getConnection(); + System.out.println("Redis 연결 성공"); + } catch (RedisConnectionFailureException e) { + System.err.println("Redis 연결 실패: " + e.getMessage()); + } + } + + + @AfterAll + static void afterAll(@Qualifier("redisConnectionFactory") LettuceConnectionFactory connectionFactory) { + connectionFactory.getConnection().flushDb(); + } + + @DisplayName("RefreshToken이 발급될 때 RefreshToken과 ID를 Redis에 저장된다") + @Test + void saveRefreshTokenWhenTokenReleased() { + // when + String refreshToken = jwtService.provideRefreshToken(member, ""); + refreshTokenService.saveRefreshToken(refreshToken, member); + // then + assertThat(member.getId().toString()).isEqualTo(refreshTokenRedisRepository.findByRefreshToken(refreshToken)); + } +} diff --git a/src/test/java/spring/backend/core/application/RotateAccessTokenServiceTest.java b/src/test/java/spring/backend/core/application/RotateAccessTokenServiceTest.java new file mode 100644 index 000000000..7477e8303 --- /dev/null +++ b/src/test/java/spring/backend/core/application/RotateAccessTokenServiceTest.java @@ -0,0 +1,94 @@ +package spring.backend.core.application; + +import com.maxmind.geoip2.exception.GeoIp2Exception; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import spring.backend.auth.application.RefreshTokenService; +import spring.backend.auth.application.RotateAccessTokenService; +import spring.backend.auth.exception.AuthenticationErrorCode; +import spring.backend.auth.infrastructure.redis.repository.RefreshTokenRedisRepository; +import spring.backend.core.exception.DomainException; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.infrastructure.persistence.jpa.adapter.MemberRepositoryImpl; + +import java.io.IOException; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +public class RotateAccessTokenServiceTest { + @Autowired + private RotateAccessTokenService rotateAccessTokenService; + + @Autowired + private JwtService jwtService; + + @Autowired + private GeoLocationService geoLocationService; + + @Autowired + private RefreshTokenRedisRepository refreshTokenRedisRepository; + + @Autowired + private RefreshTokenService refreshTokenService; + + private String newIp; + private String savedIp; + + + private final UUID memberId = UUID.randomUUID(); + + private final Member member = Member.builder() + .id(memberId) + .email("test@test.com") + .build(); + @Autowired + private MemberRepositoryImpl memberRepositoryImpl; + + @BeforeEach + void setUp() { + newIp = "210.180.165.70"; // 부산 + savedIp = "110.12.115.169"; // 서울 + } + + @AfterEach + void tearDown() { + refreshTokenRedisRepository.deleteAll(); + } + + @DisplayName("Cookie에 refreshToken이 존재하지 않는 경우 예외를 발생시킨다.") + @Test + void throwExceptionWhenRefreshTokenNotExistsInCookie() { + // when, then + assertThatThrownBy(() -> rotateAccessTokenService.rotateToken(null, "")) + .isInstanceOf(DomainException.class) + .hasMessage("쿠키값이 존재하지 않습니다."); + } + + @DisplayName("100km 밖에서 토큰 재발급을 시도한 경우 예외를 발생시킨다.") + @Test + void throwExceptionWhenTokenRotateAttemptFrom100km() throws IOException, GeoIp2Exception { + boolean isOver100km = geoLocationService.checkUserLocation(newIp, savedIp); + // when, then + assertThat(isOver100km).isTrue(); + } + + @DisplayName("Redis에 저장된 RefreshToken의 IP와 새로운 IP가 100km 이상 차이가 나는 경우 예외를 발생시킨다.") + @Test + void throwExceptionWhenIpDistanceIsOver100km() throws Exception { + // given + memberRepositoryImpl.save(member); + String refreshToken = jwtService.provideRefreshToken(member, savedIp); + refreshTokenService.saveRefreshToken(refreshToken, member); + // when, then + DomainException ex = assertThrows(DomainException.class, () -> rotateAccessTokenService.rotateToken(refreshToken, newIp), "100km 밖에서 토큰 재발급을 시도했습니다."); + assertThat(ex.getCode()).isEqualTo(AuthenticationErrorCode.TOKEN_ROTATE_ATTEMPT_FROM_INVALID_LOCATION.name()); + } +} diff --git a/src/test/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolverTest.java b/src/test/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolverTest.java new file mode 100644 index 000000000..da6f0eed5 --- /dev/null +++ b/src/test/java/spring/backend/core/configuration/argumentresolver/AuthorizedMemberArgumentResolverTest.java @@ -0,0 +1,90 @@ +package spring.backend.core.configuration.argumentresolver; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.MethodParameter; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; +import spring.backend.core.exception.DomainException; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.value.Role; +import spring.backend.member.exception.MemberErrorCode; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class AuthorizedMemberArgumentResolverTest { + + @InjectMocks + private AuthorizedMemberArgumentResolver authorizedMemberArgumentResolver; + + @Mock + private LoginMemberArgumentResolver loginMemberArgumentResolver; + + @Mock + private NativeWebRequest webRequest; + + @Mock + private ModelAndViewContainer mavContainer; + + private Member member; + private Member guest; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + member = Member.builder() + .id(UUID.randomUUID()) + .role(Role.MEMBER) + .build(); + guest = Member.builder() + .id(UUID.randomUUID()) + .role(Role.GUEST) + .build(); + } + + @DisplayName("AuthorizedMember 어노테이션이 있는 경우 지원한다") + @Test + public void supportsParameterReturnsTrueForLoginMember() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthorizedMember.class)).thenReturn(true); + Assertions.assertTrue(authorizedMemberArgumentResolver.supportsParameter(parameter)); + } + + @DisplayName("Authorization 헤더에 유효한 토큰이 있을 때 AuthorizedMember 객체를 반환한다") + @Test + public void returnsAuthorizedMemberObject_whenAuthorizationHeaderIsProvided() throws Exception { + // when + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthorizedMember.class)).thenReturn(true); + when(loginMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, null)).thenReturn(member); + + // then + Object result = authorizedMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, null); + assertNotNull(result); + assertThat(result).isEqualTo(member); + } + + @DisplayName("Authorization 헤더에 유효한 토큰이 있을 때 Guest인 경우 예외를 발생시킨다") + @Test + public void throwsNotAuthorizedMemberException_whenGuestMemberIsProvided() throws Exception { + // when + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthorizedMember.class)).thenReturn(true); + when(loginMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, null)).thenReturn(guest); + + // then + DomainException exception = assertThrows(DomainException.class, () -> authorizedMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, null)); + assertThat(exception.getCode()).isEqualTo(MemberErrorCode.NOT_AUTHORIZED_MEMBER.name()); + } +} diff --git a/src/test/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolverTest.java b/src/test/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolverTest.java new file mode 100644 index 000000000..ed5722912 --- /dev/null +++ b/src/test/java/spring/backend/core/configuration/argumentresolver/LoginMemberArgumentResolverTest.java @@ -0,0 +1,91 @@ +package spring.backend.core.configuration.argumentresolver; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.MethodParameter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; +import spring.backend.core.application.JwtService; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class LoginMemberArgumentResolverTest { + + @InjectMocks + private LoginMemberArgumentResolver loginMemberArgumentResolver; + + @Mock + private JwtService jwtService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private NativeWebRequest webRequest; + + @Mock + private ModelAndViewContainer mavContainer; + + private UUID memberId; + private String token; + private Member member; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + memberId = UUID.randomUUID(); + member = Member.builder() + .id(memberId) + .build(); + token = "mockToken"; + when(jwtService.provideAccessToken(any(Member.class))).thenReturn(token); + } + + @DisplayName("LoginMember 어노테이션이 있는 경우 지원한다") + @Test + public void supportsParameterReturnsTrueForLoginMember() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(LoginMember.class)).thenReturn(true); + Assertions.assertTrue(loginMemberArgumentResolver.supportsParameter(parameter)); + } + + @DisplayName("쿠키에 유효한 토큰이 있을 때 Member 객체를 반환한다") + @Test + public void returnsMemberObject_whenValidTokenInCookie() throws Exception { + // given + String cookieName = "access_token"; + MockHttpServletRequest mockRequest = new MockHttpServletRequest(); + System.out.println("token: " + token); + System.out.println("member : " + member); + mockRequest.setCookies(new Cookie(cookieName, token)); + + // when + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(LoginMember.class)).thenReturn(true); + when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(mockRequest); + when(jwtService.extractMemberId(token)).thenReturn(memberId); + when(memberRepository.findById(memberId)).thenReturn(member); + + // then + Object result = loginMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, null); + assertNotNull(result); + assertThat(result).isInstanceOf(Member.class); + assertThat(((Member) result).getId()).isEqualTo(memberId); + } +} diff --git a/src/test/java/spring/backend/core/converter/ImageConverterTest.java b/src/test/java/spring/backend/core/converter/ImageConverterTest.java new file mode 100644 index 000000000..f3f3e70f2 --- /dev/null +++ b/src/test/java/spring/backend/core/converter/ImageConverterTest.java @@ -0,0 +1,47 @@ +package spring.backend.core.converter; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import spring.backend.activity.domain.value.Keyword.Category; +import spring.backend.core.configuration.property.ImageProperty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@SpringBootTest +class ImageConverterTest { + + @Autowired + private ImageConverter imageConverter; + + @Autowired + private ImageProperty imageProperty; + + @DisplayName("주어진 카테고리에 맞는 이미지 URL을 반환한다") + @Test + void mapCategoryToImageUrl() { + assertEquals(imageProperty.getSelfDevelopmentImageUrl(), imageConverter.convertToImageUrl(Category.SELF_DEVELOPMENT)); + assertEquals(imageProperty.getHealthImageUrl(), imageConverter.convertToImageUrl(Category.HEALTH)); + assertEquals(imageProperty.getCultureArtImageUrl(), imageConverter.convertToImageUrl(Category.CULTURE_ART)); + assertEquals(imageProperty.getEntertainmentImageUrl(), imageConverter.convertToImageUrl(Category.ENTERTAINMENT)); + assertEquals(imageProperty.getRelaxationImageUrl(), imageConverter.convertToImageUrl(Category.RELAXATION)); + assertEquals(imageProperty.getSocialImageUrl(), imageConverter.convertToImageUrl(Category.SOCIAL)); + + assertNull(imageConverter.convertToImageUrl(null)); + } + + @DisplayName("주어진 카테고리에 맞는 투명도 30% 이미지 URL을 반환한다") + @Test + void mapCategoryTransparent30ImageUrl() { + assertEquals(imageProperty.getTransparent30SelfDevelopmentImageUrl(), imageConverter.convertToTransparent30ImageUrl(Category.SELF_DEVELOPMENT)); + assertEquals(imageProperty.getTransparent30HealthImageUrl(), imageConverter.convertToTransparent30ImageUrl(Category.HEALTH)); + assertEquals(imageProperty.getTransparent30CultureArtImageUrl(), imageConverter.convertToTransparent30ImageUrl(Category.CULTURE_ART)); + assertEquals(imageProperty.getTransparent30EntertainmentImageUrl(), imageConverter.convertToTransparent30ImageUrl(Category.ENTERTAINMENT)); + assertEquals(imageProperty.getTransparent30RelaxationImageUrl(), imageConverter.convertToTransparent30ImageUrl(Category.RELAXATION)); + assertEquals(imageProperty.getTransparent30SocialImageUrl(), imageConverter.convertToTransparent30ImageUrl(Category.SOCIAL)); + + assertNull(imageConverter.convertToTransparent30ImageUrl(null)); + } +} diff --git a/src/test/java/spring/backend/core/infrastructure/SequentialUUIDGeneratorTest.java b/src/test/java/spring/backend/core/infrastructure/SequentialUUIDGeneratorTest.java new file mode 100644 index 000000000..aa83e2fbc --- /dev/null +++ b/src/test/java/spring/backend/core/infrastructure/SequentialUUIDGeneratorTest.java @@ -0,0 +1,80 @@ +package spring.backend.core.infrastructure; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.domain.value.Provider; +import spring.backend.member.domain.value.Role; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +@SpringBootTest +public class SequentialUUIDGeneratorTest { + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("UUID가 순차적으로 생성되는지 확인한다.") + @Test + void testSequentialUUIDGenerator() { + // given + Member member1 = Member.builder() + .provider(Provider.GOOGLE) + .role(Role.GUEST) + .email("test1@test.com") + .build(); + + Member member2 = Member.builder() + .provider(Provider.GOOGLE) + .role(Role.GUEST) + .email("test2@test.com") + .build(); + + // when + Member saverMember1 = memberRepository.save(member1); + Member savedMember2 = memberRepository.save(member2); + + // then + assertNotNull(saverMember1.getId()); + assertNotNull(savedMember2.getId()); + + long timestamp1 = saverMember1.getId().getMostSignificantBits() >>> 16; + long timestamp2 = savedMember2.getId().getMostSignificantBits() >>> 16; + assertTrue(timestamp2 >= timestamp1); + } + + @DisplayName("생성순서를 변경할 경우, UUID가 순차적으로 생성되는지 확인한다.") + @Test + void failedTestSequentialUUIDGenerator() { + // given + Member member1 = Member.builder() + .provider(Provider.GOOGLE) + .role(Role.GUEST) + .email("test1@test.com") + .build(); + + Member member2 = Member.builder() + .provider(Provider.GOOGLE) + .role(Role.GUEST) + .email("test2@test.com") + .build(); + + // when + Member savedMember2 = memberRepository.save(member2); + Member saverMember1 = memberRepository.save(member1); + + // then + assertNotNull(saverMember1.getId()); + assertNotNull(savedMember2.getId()); + + long timestamp1 = saverMember1.getId().getMostSignificantBits() >>> 16; + long timestamp2 = savedMember2.getId().getMostSignificantBits() >>> 16; + + assertTrue(timestamp2 <= timestamp1); + } +} diff --git a/src/test/java/spring/backend/core/util/EmailUtilTest.java b/src/test/java/spring/backend/core/util/EmailUtilTest.java new file mode 100644 index 000000000..1736fc5ff --- /dev/null +++ b/src/test/java/spring/backend/core/util/EmailUtilTest.java @@ -0,0 +1,70 @@ +package spring.backend.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import spring.backend.core.exception.DomainException; +import spring.backend.core.util.email.EmailUtil; +import spring.backend.core.util.email.dto.request.SendEmailRequest; +import spring.backend.core.util.email.exception.MailErrorCode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(MockitoExtension.class) +public class EmailUtilTest { + @InjectMocks + private EmailUtil emailUtil; + + private SendEmailRequest sendEmailRequest; + + @DisplayName("SendEmailRequest의 to 값이 올바르지 않은 이메일 형식의 경우 예외를 반환한다.") + @Test + void throwExceptionWhenToInRequestIsInvalid() { + // GIVEN + sendEmailRequest = new SendEmailRequest("test", "Test Subject", "Test Content"); + + // WHEN & THEN + DomainException ex = assertThrows(DomainException.class, () -> emailUtil.send(sendEmailRequest), "올바르지 않은 이메일 주소입니다."); + assertThat(ex.getCode()).isEqualTo(MailErrorCode.INVALID_MAIL_ADDRESS.name()); + assertThat(ex.getMessage()).isEqualTo(MailErrorCode.INVALID_MAIL_ADDRESS.getMessage()); + } + + @DisplayName("SendEmailRequest의 to 값이 null인 경우 예외를 반환한다.") + @Test + void throwExceptionWhenToInRequestIsNull() { + // GIVEN + sendEmailRequest = new SendEmailRequest(null, "Test Subject", "Test Content"); + + // WHEN & THEN + DomainException ex = assertThrows(DomainException.class, () -> emailUtil.send(sendEmailRequest), "올바르지 않은 이메일 주소입니다."); + assertThat(ex.getCode()).isEqualTo(MailErrorCode.INVALID_MAIL_ADDRESS.name()); + assertThat(ex.getMessage()).isEqualTo(MailErrorCode.INVALID_MAIL_ADDRESS.getMessage()); + } + + @DisplayName("SendEmailRequest의 subject가 비어있는 경우 예외를 반환한다.") + @Test + void throwExceptionWhenSubjectInRequestIsNull() { + // GIVEN + sendEmailRequest = new SendEmailRequest("test@naver.com", "", "Test Content"); + + // WHEN & THEN + DomainException ex = assertThrows(DomainException.class, () -> emailUtil.send(sendEmailRequest), "메일 제목이 없습니다."); + assertThat(ex.getCode()).isEqualTo(MailErrorCode.NO_MAIL_TITLE.name()); + assertThat(ex.getMessage()).isEqualTo(MailErrorCode.NO_MAIL_TITLE.getMessage()); + } + + @DisplayName("SendEmailRequest의 text가 비어있는 경우 예외를 반환한다.") + @Test + void throwExceptionWhenTextInRequestIsNull() { + // GIVEN + sendEmailRequest = new SendEmailRequest("test@naver.com", "Test Subject", ""); + + // WHEN & THEN + DomainException ex = assertThrows(DomainException.class, () -> emailUtil.send(sendEmailRequest), "메일 내용이 없습니다."); + assertThat(ex.getCode()).isEqualTo(MailErrorCode.NO_MAIL_CONTENT.name()); + assertThat(ex.getMessage()).isEqualTo(MailErrorCode.NO_MAIL_CONTENT.getMessage()); + } +} diff --git a/src/test/java/spring/backend/core/util/GeoUtilTest.java b/src/test/java/spring/backend/core/util/GeoUtilTest.java new file mode 100644 index 000000000..459d317a2 --- /dev/null +++ b/src/test/java/spring/backend/core/util/GeoUtilTest.java @@ -0,0 +1,49 @@ +package spring.backend.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import spring.backend.core.util.geo.GeoUtil; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class GeoUtilTest { + @Test + @DisplayName("서울(37.5665, 126.9780) - 부산(35.1796, 129.0756) 거리 검증") + void seoulToBusan() { + // Given + double seoulLat = 37.5665; + double seoulLon = 126.9780; + double busanLat = 35.1796; + double busanLon = 129.0756; + + // When + double distance = GeoUtil.calculateDistanceBetweenTwoCoordinate( + seoulLat, seoulLon, + busanLat, busanLon + ); + + // Then (실제 거리: 약 325km) + assertEquals(325.0, distance, 10.0); + } + + @Test + @DisplayName("서울(37.5665, 126.9780) - 인천(37.4500, 126.7000) 거리 검증") + void seoulToIncheon() { + double distance = GeoUtil.calculateDistanceBetweenTwoCoordinate( + 37.5665, 126.9780, + 37.4500, 126.7000 + ); + assertEquals(28.0, distance, 2.0); + } + + @Test + @DisplayName("동일 좌표 거리 계산") + void sameCoordinates() { + double distance = GeoUtil.calculateDistanceBetweenTwoCoordinate( + 37.5665, 126.9780, + 37.5665, 126.9780 + ); + assertEquals(0.0, distance, 0.0); + } + +} diff --git a/src/test/java/spring/backend/core/util/TimeUtilTest.java b/src/test/java/spring/backend/core/util/TimeUtilTest.java new file mode 100644 index 000000000..a79521887 --- /dev/null +++ b/src/test/java/spring/backend/core/util/TimeUtilTest.java @@ -0,0 +1,53 @@ +package spring.backend.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TimeUtilTest { + + @DisplayName("오전 10시 30분을 입력하면 10시 30분으로 변환된다.") + @Test + void testToLocalTime_AM() { + LocalTime time = TimeUtil.toLocalTime("오전", 10, 30); + assertEquals(10, time.getHour()); + assertEquals(30, time.getMinute()); + } + + @DisplayName("오후 10시 30분을 입력하면 22시 30분으로 변환된다.") + @Test + void testToLocalTime_PM() { + LocalTime time = TimeUtil.toLocalTime("오후", 10, 30); + assertEquals(22, time.getHour()); + assertEquals(30, time.getMinute()); + } + + @DisplayName("오전 0시 입력 시 '오전' 반환") + @Test + void testToMeridiem_Midnight() { + LocalTime time = LocalTime.of(0, 0); + String meridiem = TimeUtil.toMeridiem(time); + assertEquals("오전", meridiem); + } + + @DisplayName("오후 12시 입력 시 '오후' 반환") + @Test + void testToMeridiem_Noon() { + LocalTime time = LocalTime.of(12, 0); + String meridiem = TimeUtil.toMeridiem(time); + assertEquals("오후", meridiem); + } + + @DisplayName("오전 0시, 오후 12시는 12로 변환된다.") + @Test + void testToHour() { + LocalTime time = LocalTime.of(0, 0); + assertEquals(12, TimeUtil.toHour(time)); + + time = LocalTime.of(12, 0); + assertEquals(12, TimeUtil.toHour(time)); + } +} \ No newline at end of file diff --git a/src/test/java/spring/backend/member/domain/entity/MemberTest.java b/src/test/java/spring/backend/member/domain/entity/MemberTest.java new file mode 100644 index 000000000..829b00043 --- /dev/null +++ b/src/test/java/spring/backend/member/domain/entity/MemberTest.java @@ -0,0 +1,38 @@ +package spring.backend.member.domain.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import spring.backend.core.exception.DomainException; +import spring.backend.member.exception.MemberErrorCode; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberTest { + + @DisplayName("이메일 알림이 이미 활성화된 상태에서 다시 활성화하려고 하면 예외가 발생한다.") + @Test + void changeEmailNotification_throwsException_whenAlreadyEnabled() { + // Given + Member member = Member.builder() + .emailNotification(true) + .build(); + + // Then + DomainException ex = assertThrows(DomainException.class, () -> member.changeEmailNotification(true)); + assertEquals(MemberErrorCode.ALREADY_ENABLE_EMAIL_NOTIFICATION.getMessage(), ex.getMessage()); + } + + @DisplayName("이메일 알림이 이미 비활성화된 상태에서 다시 비활성화하려고 하면 예외가 발생한다.") + @Test + void changeEmailNotification_throwsException_whenAlreadyDisabled() { + // Given + Member member = Member.builder() + .emailNotification(false) + .build(); + + // Then + DomainException ex = assertThrows(DomainException.class, () -> member.changeEmailNotification(false)); + assertEquals(MemberErrorCode.ALREADY_DISABLE_EMAIL_NOTIFICATION.getMessage(), ex.getMessage()); + } +} diff --git a/src/test/java/spring/backend/member/domain/service/CreateMemberWithOAuthServiceTest.java b/src/test/java/spring/backend/member/domain/service/CreateMemberWithOAuthServiceTest.java new file mode 100644 index 000000000..c1d0e1f8b --- /dev/null +++ b/src/test/java/spring/backend/member/domain/service/CreateMemberWithOAuthServiceTest.java @@ -0,0 +1,50 @@ +package spring.backend.member.domain.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.domain.value.Provider; +import spring.backend.member.domain.value.Role; +import spring.backend.member.presentation.dto.request.CreateMemberWithOAuthRequest; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CreateMemberWithOAuthServiceTest { + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private CreateMemberWithOAuthService createMemberWithOAuthService; + + @DisplayName("Role이 GUEST인 경우 닉네임은 null이어야 한다") + @Test + void createGuestMember_returnsGuestMemberWithoutNickname() { + // given + CreateMemberWithOAuthRequest request = CreateMemberWithOAuthRequest.builder() + .provider(Provider.GOOGLE) + .email("jogakjogak@gmail.com") + .build(); + + // when + Member newMember = Member.createGuestMember(request.getProvider(), request.getEmail()); + when(memberRepository.save(any(Member.class))).thenReturn(newMember); + Member result = createMemberWithOAuthService.createMemberWithOAuth(request); + + // then + assertNotNull(result); + assertEquals(Provider.GOOGLE, result.getProvider()); + assertEquals("jogakjogak@gmail.com", result.getEmail()); + assertEquals(Role.GUEST, result.getRole()); + assertNull(result.getNickname()); + verify(memberRepository, times(1)).save(any(Member.class)); + } +} diff --git a/src/test/java/spring/backend/member/domain/service/ValidateNicknameServiceTest.java b/src/test/java/spring/backend/member/domain/service/ValidateNicknameServiceTest.java new file mode 100644 index 000000000..a6113d7be --- /dev/null +++ b/src/test/java/spring/backend/member/domain/service/ValidateNicknameServiceTest.java @@ -0,0 +1,73 @@ +package spring.backend.member.domain.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import spring.backend.member.domain.repository.MemberRepository; +import spring.backend.member.domain.value.Role; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ValidateNicknameServiceTest { + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private ValidateNicknameService validateNicknameService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("닉네임이 공백일 때 예외가 발생한다.") + void throwExceptionWhenNicknameIsBlank() { + // Given + String nickname = " "; + + // When & Then + assertFalse(validateNicknameService.validateNickname(nickname)); + } + + @Test + @DisplayName("닉네임 길이가 6자를 초과할 때 예외가 발생한다.") + void throwExceptionWhenNicknameLengthIsInvalid() { + // Given + String nickname = "1234567"; + + // When & Then + assertFalse(validateNicknameService.validateNickname(nickname)); + } + + @Test + @DisplayName("이미 등록된 닉네임일 경우 예외가 발생한다.") + void throwExceptionWhenNicknameIsAlreadyRegistered() { + // Given + String nickname = "등록된이름"; + when(memberRepository.existsByNicknameAndRole(nickname, Role.MEMBER)).thenReturn(true); + + // When & Then + assertFalse(validateNicknameService.validateNickname(nickname)); + + // Mock 객체 정상 동작 확인 + verify(memberRepository).existsByNicknameAndRole(nickname, Role.MEMBER); + } + + @ParameterizedTest + @DisplayName("올바른 형식의 이름일 경우 성공한다.") + @ValueSource(strings = {"ㅍ카칩", "ㄱ", "포ㅋ칩", "포카ㅊ", "조각조각ㅈㄱ", "q", "qwerty"}) + void validateNicknameWithInitialConsonants(String nickname) { + // When & Then + assertTrue(validateNicknameService.validateNickname(nickname)); + } +} diff --git a/src/test/java/spring/backend/member/dto/response/MemberProfileResponseTest.java b/src/test/java/spring/backend/member/dto/response/MemberProfileResponseTest.java new file mode 100644 index 000000000..e7125fe5d --- /dev/null +++ b/src/test/java/spring/backend/member/dto/response/MemberProfileResponseTest.java @@ -0,0 +1,29 @@ +package spring.backend.member.dto.response; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.presentation.dto.response.MemberProfileResponse; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class MemberProfileResponseTest { + + @DisplayName("from 메서드가 올바르게 작동하는지 확인한다") + @Test + void testFromMethodInMemberProfileResponse() { + // given + Member member = Member.builder() + .email("jogakjogak@gmail.com") + .emailNotification(true) + .build(); + + // when + MemberProfileResponse response = MemberProfileResponse.from(member); + + // then + assertThat(response.email()).isEqualTo("jogakjogak@gmail.com"); + assertThat(response.isEmailNotificationEnabled()).isTrue(); + } +} diff --git a/src/test/java/spring/backend/quickstart/domain/repository/QuickStartRepositoryTest.java b/src/test/java/spring/backend/quickstart/domain/repository/QuickStartRepositoryTest.java new file mode 100644 index 000000000..230edff1d --- /dev/null +++ b/src/test/java/spring/backend/quickstart/domain/repository/QuickStartRepositoryTest.java @@ -0,0 +1,46 @@ +package spring.backend.quickstart.domain.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import spring.backend.activity.domain.value.Type; +import spring.backend.quickstart.domain.entity.QuickStart; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class QuickStartRepositoryTest { + + @Autowired + private QuickStartRepository quickStartRepository; + + private QuickStart quickStart; + + @BeforeEach + void setUp() { + quickStart = QuickStart.builder() + .memberId(UUID.randomUUID()) + .name("Test QuickStart") + .startTime(LocalTime.of(12, 30)) + .spareTime(60) + .type(Type.ONLINE) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .deleted(false) + .build(); + } + + @Test + void testSaveAndFindQuickStart() { + QuickStart savedQuickStart = quickStartRepository.save(quickStart); + QuickStart foundQuickStart = quickStartRepository.findById(savedQuickStart.getId()); + + assertThat(foundQuickStart).isNotNull(); + assertThat(foundQuickStart.getStartTime()).isEqualTo(quickStart.getStartTime()); + } +} diff --git a/src/test/java/spring/backend/quickstart/domain/service/CreateQuickStartServiceTest.java b/src/test/java/spring/backend/quickstart/domain/service/CreateQuickStartServiceTest.java new file mode 100644 index 000000000..e75c883c1 --- /dev/null +++ b/src/test/java/spring/backend/quickstart/domain/service/CreateQuickStartServiceTest.java @@ -0,0 +1,79 @@ +package spring.backend.quickstart.domain.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import spring.backend.activity.domain.value.Type; +import spring.backend.core.exception.DomainException; +import spring.backend.core.util.TimeUtil; +import spring.backend.member.domain.entity.Member; +import spring.backend.member.domain.value.Role; +import spring.backend.quickstart.domain.entity.QuickStart; +import spring.backend.quickstart.domain.repository.QuickStartRepository; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; +import spring.backend.quickstart.exception.QuickStartErrorCode; + +import java.time.LocalTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CreateQuickStartServiceTest { + + @InjectMocks + private CreateQuickStartService createQuickStartService; + + @Mock + private QuickStartRepository quickStartRepository; + + private Member member; + private QuickStartRequest request; + + @BeforeEach + public void setUp() { + member = Member.builder() + .role(Role.MEMBER) + .build(); + request = new QuickStartRequest( + "등교", + 12, + 30, + "오전", + 300, + Type.ONLINE + ); + } + + @DisplayName("요청이 null인 경우 예외가 발생한다") + @Test + public void createQuickStart_NullRequest_ThrowsException() { + // when + DomainException ex = assertThrows(DomainException.class, () -> createQuickStartService.createQuickStart(member, null)); + + // then + assertEquals(QuickStartErrorCode.NOT_EXIST_QUICK_START_CONDITION.getMessage(), ex.getMessage()); + } + + @DisplayName("유효한 빠른 시작 요청인 경우 저장된 ID를 반환한다") + @Test + public void createQuickStart_ValidRequest_ReturnsSavedQuickStartId() { + LocalTime startTime = TimeUtil.toLocalTime(request.meridiem(), request.hour(), request.minute()); + QuickStart quickStart = QuickStart.create(member.getId(), request.name(), startTime, request.spareTime(), request.type()); + when(quickStartRepository.save(any(QuickStart.class))).thenReturn(quickStart); + + // when + Long savedQuickStartId = createQuickStartService.createQuickStart(member, request); + + // then + assertEquals(quickStart.getId(), savedQuickStartId); + verify(quickStartRepository).save(any(QuickStart.class)); + } +} diff --git a/src/test/java/spring/backend/quickstart/dto/request/QuickStartRequestTest.java b/src/test/java/spring/backend/quickstart/dto/request/QuickStartRequestTest.java new file mode 100644 index 000000000..f96718531 --- /dev/null +++ b/src/test/java/spring/backend/quickstart/dto/request/QuickStartRequestTest.java @@ -0,0 +1,75 @@ +package spring.backend.quickstart.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import spring.backend.activity.domain.value.Type; +import spring.backend.quickstart.presentation.dto.request.QuickStartRequest; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class QuickStartRequestTest { + + private final Validator validator; + + public QuickStartRequestTest() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + this.validator = factory.getValidator(); + } + } + + @Nested + @DisplayName("name 필드 검증 테스트") + class NameValidationTests { + + @Test + @DisplayName("null 값일 경우 에러가 발생한다.") + void whenNameIsNull_thenValidationFails() { + QuickStartRequest request = new QuickStartRequest(null, 12, 30, "오전", 300, Type.OFFLINE); + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(violation -> violation.getMessage().contains("이름은 필수 입력 항목입니다.")); + } + + @ParameterizedTest + @DisplayName("올바른 형식의 이름일 경우 성공한다.") + @ValueSource(strings = {"등교", "이름테스트", "띄어쓰기 포함 10", "사용자1", "ㄱ", "ㄱ나다라ㅁ바사ㅇㅈㅋ"}) + void whenNameIsValid_thenValidationSucceeds(String name) { + QuickStartRequest request = new QuickStartRequest(name, 12, 30, "오전", 300, Type.OFFLINE); + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @ParameterizedTest + @DisplayName("형식에 맞지 않는 이름일 경우 에러가 발생한다.") + @ValueSource(strings = {" 이름", "이름 ", "이름@이름", "공백 공백"}) + void whenNameIsInvalid_thenValidationFails(String name) { + QuickStartRequest request = new QuickStartRequest(name, 12, 30, "오전", 300, Type.OFFLINE); + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(violation -> violation.getMessage().contains("이름은 한글(초성 포함), 영문, 숫자 및 공백만 입력 가능하며,")); + } + + @Test + @DisplayName("10자를 초과하는 경우 에러가 발생한다.") + void whenNameExceedsMaxLength_thenValidationFails() { + String name = "매우몹시너무긴이름longname"; + QuickStartRequest request = new QuickStartRequest(name, 12, 30, "오전", 300, Type.OFFLINE); + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(violation -> violation.getMessage().contains("최대 10자까지 입력 가능합니다.")); + } + } +}