diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..20c6f542 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,19 @@ + + +## ✨ 이슈 내용 + +- + +## ✅ 체크리스트 + +- [ ] Assignees / Labels / Milestone 선택 +- [ ] +- [ ] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..627d852f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ + + +## 📌 관련 이슈 + + +## ✨ 어떤 이유로 변경된 내용인지 + + + +## 🙏 검토 혹은 리뷰어에게 남기고 싶은 말 diff --git a/.github/workflows/api-CD-dev.yml b/.github/workflows/api-CD-dev.yml new file mode 100644 index 00000000..1d6c9183 --- /dev/null +++ b/.github/workflows/api-CD-dev.yml @@ -0,0 +1,105 @@ +# 워크플로우의 이름 지정 +name: Umbba API Server CD (Develop) + +# 해당 workflow가 언제 실행될 것인지에 대한 트리거를 지정 +on: + push: + branches: [ "develop" ] + paths: + - umbba-api/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + - .github/workflows/** + +env: + S3_BUCKET_NAME: umbba-develop-storage + +jobs: + build: + name: Code deployment + + # 실행 환경 + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + aws-region: ap-northeast-2 + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 4) FCM secret key 파일 생성 + - name: FCM secret key 파일 생성 + run: | + cd ./umbba-api/src/main/resources + + mkdir ./firebase + cd ./firebase + + aws s3 cp --region ap-northeast-2 s3://${{ secrets.S3_DEVELOP_BUCKET_NAME }}/json/umbba-fcm-firebase-adminsdk.json . + + shell: bash + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-api:bootJar -x test + + # 디렉토리 생성 + - name: Make Directory + run: mkdir -p deploy + + # Jar 파일 복사 + - name: Copy Jar + run: cp ./umbba-api/build/libs/*.jar ./deploy + # run: cp -r ./umbba-api/src/main/* ./deploy + + # appspec.yml, script files 파일 복사 + - name: Copy files + run: cp ./scripts/umbba-api-dev/* ./deploy + + - name: Make zip file + run: zip -r ./umbba-api.zip ./deploy + shell: bash + + - name: Upload to S3 + run: aws s3 cp --region ap-northeast-2 ./umbba-api.zip s3://$S3_BUCKET_NAME/ + + # Deploy + - name: Deploy + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + run: + aws deploy create-deployment + --application-name umbba-develop-server-codedeploy + --deployment-group-name umbba-api-server-codedeploy-group + --file-exists-behavior OVERWRITE + --s3-location bucket=umbba-develop-storage,bundleType=zip,key=umbba-api.zip + --region ap-northeast-2 \ No newline at end of file diff --git a/.github/workflows/api-CD-prod.yml b/.github/workflows/api-CD-prod.yml new file mode 100644 index 00000000..abc3ea10 --- /dev/null +++ b/.github/workflows/api-CD-prod.yml @@ -0,0 +1,105 @@ +# 워크플로우의 이름 지정 +name: Umbba API Server CD (Production) + +# 해당 workflow가 언제 실행될 것인지에 대한 트리거를 지정 +on: + push: + branches: [ "main" ] + paths: + - umbba-api/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + - .github/workflows/** + +env: + S3_BUCKET_NAME: umbba-storage + +jobs: + build: + name: Code deployment + + # 실행 환경 + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 4) FCM secret key 파일 생성 + - name: FCM secret key 파일 생성 + run: | + cd ./umbba-api/src/main/resources + + mkdir ./firebase + cd ./firebase + + aws s3 cp --region ap-northeast-2 s3://${{ secrets.S3_BUCKET_NAME }}/json/umbba-fcm-firebase-adminsdk.json . + + shell: bash + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-api:bootJar -x test + + # 디렉토리 생성 + - name: Make Directory + run: mkdir -p deploy + + # Jar 파일 복사 + - name: Copy Jar + run: cp ./umbba-api/build/libs/*.jar ./deploy + # run: cp -r ./umbba-api/src/main/* ./deploy + + # appspec.yml, script files 파일 복사 + - name: Copy files + run: cp ./scripts/umbba-api-prod/* ./deploy + + - name: Make zip file + run: zip -r ./umbba-api.zip ./deploy + shell: bash + + - name: Upload to S3 + run: aws s3 cp --region ap-northeast-2 ./umbba-api.zip s3://$S3_BUCKET_NAME/ + + # Deploy + - name: Deploy + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }} + run: + aws deploy create-deployment + --application-name umbba-server-codedeploy + --deployment-group-name umbba-api-server-codedeploy-group + --file-exists-behavior OVERWRITE + --s3-location bucket=umbba-storage,bundleType=zip,key=umbba-api.zip + --region ap-northeast-2 \ No newline at end of file diff --git a/.github/workflows/api-CI-dev.yml b/.github/workflows/api-CI-dev.yml new file mode 100644 index 00000000..e5636bff --- /dev/null +++ b/.github/workflows/api-CI-dev.yml @@ -0,0 +1,55 @@ +name: Umbba API Server CI (Develop) + +on: + push: + branches: [ "develop" ] + paths: + - umbba-api/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + pull_request: + branches: [ "develop" ] + paths: + - umbba-api/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-api:bootJar -x test diff --git a/.github/workflows/api-CI-prod.yml b/.github/workflows/api-CI-prod.yml new file mode 100644 index 00000000..b4d51d32 --- /dev/null +++ b/.github/workflows/api-CI-prod.yml @@ -0,0 +1,55 @@ +name: Umbba API Server CI (Production) + +on: + push: + branches: [ "main" ] + paths: + - umbba-api/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + pull_request: + branches: [ "main" ] + paths: + - umbba-api/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-api:bootJar -x test \ No newline at end of file diff --git a/.github/workflows/notification-CD-dev.yml b/.github/workflows/notification-CD-dev.yml new file mode 100644 index 00000000..6fe22226 --- /dev/null +++ b/.github/workflows/notification-CD-dev.yml @@ -0,0 +1,105 @@ +# 워크플로우의 이름 지정 +name: Umbba Notification Server CD (Develop) + +# 해당 workflow가 언제 실행될 것인지에 대한 트리거를 지정 +on: + push: + branches: [ "develop" ] + paths: + - umbba-notification/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + - .github/workflows/** + +env: + S3_BUCKET_NAME: umbba-develop-storage + +jobs: + build: + name: Code deployment + + # 실행 환경 + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + aws-region: ap-northeast-2 + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 4) FCM secret key 파일 생성 + - name: FCM secret key 파일 생성 + run: | + cd ./umbba-notification/src/main/resources + + mkdir ./firebase + cd ./firebase + + aws s3 cp --region ap-northeast-2 s3://${{ secrets.S3_DEVELOP_BUCKET_NAME }}/json/umbba-fcm-firebase-adminsdk.json . + + shell: bash + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-notification:bootJar -x test + + # 디렉토리 생성 + - name: Make Directory + run: mkdir -p deploy + + # Jar 파일 복사 + - name: Copy Jar + run: cp ./umbba-notification/build/libs/*.jar ./deploy + # run: cp -r src/main/* ./deploy + + # appspec.yml, script files 파일 복사 + - name: Copy files + run: cp ./scripts/umbba-notification-dev/* ./deploy + + - name: Make zip file + run: zip -r ./umbba-notification.zip ./deploy + shell: bash + + - name: Upload to S3 + run: aws s3 cp --region ap-northeast-2 ./umbba-notification.zip s3://$S3_BUCKET_NAME/ + + # Deploy + - name: Deploy + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + run: + aws deploy create-deployment + --application-name umbba-develop-server-codedeploy + --deployment-group-name umbba-notification-server-codedeploy-group + --file-exists-behavior OVERWRITE + --s3-location bucket=umbba-develop-storage,bundleType=zip,key=umbba-notification.zip + --region ap-northeast-2 diff --git a/.github/workflows/notification-CD-prod.yml b/.github/workflows/notification-CD-prod.yml new file mode 100644 index 00000000..cd9fb8c7 --- /dev/null +++ b/.github/workflows/notification-CD-prod.yml @@ -0,0 +1,105 @@ +# 워크플로우의 이름 지정 +name: Umbba Notification Server CD (Production) + +# 해당 workflow가 언제 실행될 것인지에 대한 트리거를 지정 +on: + push: + branches: [ "main" ] + paths: + - umbba-notification/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + - .github/workflows/** + +env: + S3_BUCKET_NAME: umbba-storage + +jobs: + build: + name: Code deployment + + # 실행 환경 + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 4) FCM secret key 파일 생성 + - name: FCM secret key 파일 생성 + run: | + cd ./umbba-notification/src/main/resources + + mkdir ./firebase + cd ./firebase + + aws s3 cp --region ap-northeast-2 s3://${{ secrets.S3_BUCKET_NAME }}/json/umbba-fcm-firebase-adminsdk.json . + + shell: bash + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-notification:bootJar -x test + + # 디렉토리 생성 + - name: Make Directory + run: mkdir -p deploy + + # Jar 파일 복사 + - name: Copy Jar + run: cp ./umbba-notification/build/libs/*.jar ./deploy + # run: cp -r src/main/* ./deploy + + # appspec.yml, script files 파일 복사 + - name: Copy files + run: cp ./scripts/umbba-notification-prod/* ./deploy + + - name: Make zip file + run: zip -r ./umbba-notification.zip ./deploy + shell: bash + + - name: Upload to S3 + run: aws s3 cp --region ap-northeast-2 ./umbba-notification.zip s3://$S3_BUCKET_NAME/ + + # Deploy + - name: Deploy + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }} + run: + aws deploy create-deployment + --application-name umbba-server-codedeploy + --deployment-group-name umbba-notification-server-codedeploy-group + --file-exists-behavior OVERWRITE + --s3-location bucket=umbba-storage,bundleType=zip,key=umbba-notification.zip + --region ap-northeast-2 diff --git a/.github/workflows/notification-CI-dev.yml b/.github/workflows/notification-CI-dev.yml new file mode 100644 index 00000000..cb09fdcd --- /dev/null +++ b/.github/workflows/notification-CI-dev.yml @@ -0,0 +1,55 @@ +name: Umbba Notification Server CI (Develop) + +on: + push: + branches: [ "develop" ] + paths: + - umbba-notification/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + pull_request: + branches: [ "develop" ] + paths: + - umbba-notification/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_DEVELOP_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_DEVELOP_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-notification:bootJar -x test diff --git a/.github/workflows/notification-CI-prod.yml b/.github/workflows/notification-CI-prod.yml new file mode 100644 index 00000000..1b01c8c7 --- /dev/null +++ b/.github/workflows/notification-CI-prod.yml @@ -0,0 +1,55 @@ +name: Umbba Notification Server CI (Production) + +on: + push: + branches: [ "main" ] + paths: + - umbba-notification/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + pull_request: + branches: [ "main" ] + paths: + - umbba-notification/** + - umbba-domain/** + - umbba-common/** + - umbba-external/** + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + + # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요 + - name: checkout + uses: actions/checkout@v3 + + # 2) JDK 11버전 설치, 다른 JDK 버전을 사용하다면 수정 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + # 3) AWS Secrets Manger 환경변수 사용 + - name: Read secrets from AWS Secrets Manager into environment variables + uses: abhilash1in/aws-secrets-manager-action@v1.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + secrets: /secret/umbba-secret + parse-json: false + + # 이 워크플로우는 gradle build + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle # 실제 application build(-x 옵션을 통해 test는 제외) + run: ./gradlew umbba-notification:bootJar -x test diff --git a/.gitignore b/.gitignore index f024bee5..8ad08fda 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -application.yml +firebase/ HELP.md .gradle @@ -7,6 +7,11 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ +umbba-test.http + +## Querydsl +umbba-domain/src/main/generated + ### STS ### .apt_generated .classpath @@ -37,4 +42,4 @@ out/ ### VS Code ### .vscode/ -.DS_Store +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 9261ecd5..37813823 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,353 @@ -# Umbba-Server -최최최최최최최최최최최종본 main 브랜치 (진지) -commit 날리는자, 벌받을지어다... +# 👨‍👩‍👧‍👦 엄빠도 어렸다 +> 부모의 추억을 자식과 공유하며 공감대를 찾는 문답형 아카이빙 서비스, 엄빠도 어렸다 + + +
+ + +## 🌸 금쪽이들 +| 이동섭 | 박예준 | +| :----------------------------------------------------------: |:----------------------------------------------------------------------------------------------------------------------------------:| +| | | +| [ddongseop](https://github.com/ddongseop) | [jun02160](https://github.com/jun02160) | + +## 👻 Role + +| 담당 역할 | Role | +|:-------------------|:--------:| +| Nginx 배포, CI/CD 구축 | 이동섭 | +| DB 구축 (RDS) | 이동섭 | +| ERD 작성 | 이동섭, 박예준 | +| API 개발 | 이동섭, 박예준 | +| 소셜로그인 기능 구현 | 이동섭 | +| 푸시알림 기능 구현 | 박예준 | + + +
+ +## 🛠️ 개발 환경 + +| | | +| --- | --- | +| 통합 개발 환경 | IntelliJ | +| Spring 버전 | 2.7.13 | +| 데이터베이스 | AWS RDS(MySQL), Redis | +| 배포 | AWS EC2(Ubuntu), S3 | +| Project 빌드 관리 도구 | Gradle | +| CI/CD 툴 | Github Actions, CodeDeploy | +| ERD 다이어그램 툴 | ERDCloud | +| Java version | Java 11 | +| 패키지 구조 | 도메인 패키지 구조 | +| API 테스트 | PostMan, Swagger | +| 외부 연동 | Slack, FCM | + + +## 🔧 시스템 아키텍처 + + + +
+ +## 📜 API Docs + +### 🔗 [API Docs](https://harsh-step-7dd.notion.site/API-887ec56c3fdd48e19fec44820b63a83d?pvs=4) + +
+ +## ☁️ ERD + + +
+ +## 📂 Project Structure +``` +📂 umbba-api +├── build.gradle +└── src + ├── main + │   ├── 📂 java/sopt/org/umbba/api + │   │   ├── ApiApplication.java + │   │   ├── 🗂 config + │   │   │   ├── SecurityConfig.java + │   │   │   ├── 🗂 auth + │   │   │   │   ├── CustomJwtAuthenticationEntryPoint.java + │   │   │   │   ├── JwtAuthenticationFilter.java + │   │   │   │   └── UserAuthentication.java + │   │   │   ├── 🗂 jwt + │   │   │   │   ├── JwtProvider.java + │   │   │   │   ├── JwtValidationType.java + │   │   │   │   ├── TokenDto.java + │   │   │   │   └── TokenRepository.java + │   │   │   └── 🗂 sqs + │   │   │   ├── SqsConfig.java + │   │   │   └── 🗂 producer + │   │   │   └── SqsProducer.java + │   │   ├── 🗂 controller + │   │   │   ├── 🗂 advice + │   │   │   │   └── ControllerExceptionAdvice.java + │   │   │   ├── 🗂 health + │   │   │   │   ├── DemoController.java + │   │   │   │   ├── ServerProfileController.java + │   │   │   │   └── SlackTestController.java + │   │   │   ├── 🗂 parentchild + │   │   │   │   ├── ParentchildController.java + │   │   │   │   └── 🗂 dto + │   │   │   │   ├── 🗂 request + │   │   │   │   │   ├── InviteCodeRequestDto.java + │   │   │   │   │   ├── OnboardingInviteRequestDto.java + │   │   │   │   │   └── OnboardingReceiveRequestDto.java + │   │   │   │   └── 🗂 response + │   │   │   │   ├── InviteResultResponseDto.java + │   │   │   │   ├── OnboardingInviteResponseDto.java + │   │   │   │   └── OnboardingReceiveResponseDto.java + │   │   │   ├── 🗂 qna + │   │   │   │   ├── QnAController.java + │   │   │   │   └── 🗂 dto + │   │   │   │   ├── 🗂 request + │   │   │   │   │   └── TodayAnswerRequestDto.java + │   │   │   │   └── 🗂 response + │   │   │   │   ├── GetInvitationResponseDto.java + │   │   │   │   ├── GetMainViewResponseDto.java + │   │   │   │   ├── QnAListResponseDto.java + │   │   │   │   ├── SingleQnAResponseDto.java + │   │   │   │   └── TodayQnAResponseDto.java + │   │   │   └── 🗂 user + │   │   │   ├── AuthController.java + │   │   │   └── 🗂 dto + │   │   │   ├── 🗂 request + │   │   │   │   ├── RefreshRequestDto.java + │   │   │   │   ├── SocialLoginRequestDto.java + │   │   │   │   └── UserInfoDto.java + │   │   │   └── 🗂 response + │   │   │   └── UserLoginResponseDto.java + │   │   └── 🗂 service + │   │   ├── 🗂 notification + │   │   │   └── NotificationService.java + │   │   ├── 🗂 parentchild + │   │   │   └── ParentchildService.java + │   │   ├── 🗂 qna + │   │   │   └── QnAService.java + │   │   └── 🗂 user + │   │   ├── AuthService.java + │   │   └── 🗂 social + │   │   ├── 🗂 apple + │   │   │   ├── AppleLoginService.java + │   │   │   └── 🗂 verify + │   │   │   ├── AppleClaimsValidator.java + │   │   │   ├── AppleJwtParser.java + │   │   │   ├── EncryptUtils.java + │   │   │   └── PublicKeyGenerator.java + │   │   └── 🗂 kakao + │   │   └── KakaoLoginService.java + │   └── 📂 resources +    └── application.yaml + +📂 umbba-notification +├── build.gradle +└── src + └── main + ├── 📂 java/sopt/org/umbba/notification + │   ├── NotificationApplication.java + │   ├── 🗂 config + │   │   ├── 🗂 fcm + │   │   │   └── FCMConfig.java +    │   │   ├── 🗂 scheduler +    │   │   │   └── ScheduleConfig.java + │   │   └── 🗂 sqs + │   │   ├── SqsConfig.java + │   │   └── 🗂 consumer + │   │   └── SqsConsumer.java + │   └── 🗂 service + │   ├── 🗂 fcm + │   │   ├── FCMController.java + │   │   ├── FCMService.java + │   │   └── 🗂 dto + │   │   └── FCMMessage.java +    │   ├── 🗂 scheduler +    │   │   └── FCMScheduler.java + │   └── 🗂 slack + │   └── SlackApi.java + └── 📂 resources + ├── application.yaml + └── 🗂 firebase + └── umbba-fcm-firebase-adminsdk.json + +📂 umbba-domain +├── build.gradle +└── src + └── main + └── 📂 java/sopt/org/umbba/domain + ├── UmbbaDomainRoot.java + ├── 🗂 config + │   └── 🗂 jpa + │   └── JpaConfig.java + └── 🗂 domain + ├── 🗂 common + │   └── AuditingTimeEntity.java + ├── 🗂 parentchild + │   ├── Parentchild.java + │   ├── ParentchildRelation.java + │   ├── 🗂 dao + │   │   └── ParentchildDao.java + │   └── 🗂 repository + │   └── ParentchildRepository.java + ├── 🗂 qna + │   ├── OnboardingAnswer.java + │   ├── QnA.java + │   ├── Question.java + │   ├── QuestionSection.java + │   ├── QuestionType.java + │   ├── 🗂 dao + │   │   └── QnADao.java + │   └── 🗂 repository + │   ├── QnARepository.java + │   └── QuestionRepository.java + ├── 🗂 redis + │   └── RefreshToken.java + └── 🗂 user + ├── SocialPlatform.java + ├── User.java + └── 🗂 repository + └── UserRepository.java + +📂 umbba-common +├── build.gradle +└── src + └── main + └── 📂 java/sopt/org/umbba/common + ├── UmbbaCommonRoot.java + ├── 🗂 exception + │   ├── ErrorType.java + │   ├── SuccessType.java + │   ├── 🗂 dto + │   │   └── ApiResponse.java + │   └── 🗂 model + │   └── CustomException.java + └── 🗂 sqs + ├── MessageType.java + ├── MessageUtils.java + └── 🗂 dto + ├── FCMPushRequestDto.java + ├── FirebaseDto.java + ├── MessageDto.java + ├── PushMessage.java + └── SlackDto.java + +📂 umbba-external +├── build.gradle +└── src + └── main + └── 📂 java/sopt/org/umbba/external + ├── UmbbaExternalRoot.java + └── 🗂 client + └── 🗂 auth + ├── 🗂 apple + │   ├── AppleApiClient.java + │   └── 🗂 response + │   ├── ApplePublicKey.java + │   └── ApplePublicKeys.java + └── 🗂 kakao + ├── KakaoApiClient.java + ├── KakaoAuthApiClient.java + └── 🗂 response + ├── KakaoAccessTokenResponse.java + ├── KakaoAccount.java + ├── KakaoUserProfile.java + └── KakaoUserResponse.java +``` + +
+ +# 🌱 Branch + + +
+ +# 🙏 Commit Convention +``` +- [CHORE] : 동작에 영향 없는 코드 or 변경 없는 변경사항(주석 추가 등) or 파일명, 폴더명 수정 or 파일, 폴더 삭제 or 디렉토리 구조 변경 +- [RENAME] : 파일 이름 변경시 +- [FEAT] : 새로운 기능 구현 +- [FIX] : 버그, 오류 해결 +- [REFACTOR] : 전면 수정, 코드 리팩토링 +- [ADD] : Feat 이외의 부수적인 코드 추가, 라이브러리 추가, 새로운 파일 생성 +- [DEL] : 쓸모없는 코드 삭제 +- [CORRECT] : 주로 문법의 오류나 타입의 변경, 이름 변경시 +- [DOCS] : README나 WIKI 등의 문서 수정 +- [MERGE]: 다른 브랜치와 병합 +- [TEST] : 테스트 코드 추가/작성 +``` +- 커밋은 세부 기능 기준 +- 이슈번호 붙이는 단위 : **FEAT, FIX, REFACTOR** + + ex. `git commit -m “[FEAT] 로그인 기능 구현 #2”` + + +# 🙏 Code Convention + +> 💡 **동료들과 말투를 통일하기 위해 컨벤션을 지정합니다.** +> +> 오합지졸의 코드가 아닌, **한 사람이 짠 것같은 코드**를 작성하는 것이 추후 유지보수나 협업에서 도움이 됩니다. 내가 코드를 생각하면서 짤 수 있도록 해주는 룰이라고 생각해도 좋습니다! + +1. 기본적으로 네이밍은 **누구나 알 수 있는 쉬운 단어**를 선택한다. + - 우리는 외국인이 아니다. 쓸데없이 어려운 고급 어휘를 피한다. +2. 변수는 CamelCase를 기본으로 한다. + - userEmail, userCellPhone ... +3. URL, 파일명 등은 kebab-case를 사용한다. + - /user-email-page ... +4. 패키지명은 단어가 달라지더라도 무조건 소문자를 사용한다. + - frontend, useremail ... +5. ENUM이나 상수는 대문자로 네이밍한다. + - NORMAL_STATUS ... +6. 함수명은 소문자로 시작하고**동사**로 네이밍한다. + - getUserId(), isNormal() ... +7. 클래스명은**명사**로 작성하고 UpperCamelCase를 사용한다. + - UserEmail, Address ... +8. 객체 이름을 함수 이름에 중복해서 넣지 않는다. (= 상위 이름을 하위 이름에 중복시키지 않는다.) + - line.getLength() (O) / line.getLineLength() (X) +9. 컬렉션은 복수형을 사용하거나 컬렉션을 명시해준다. + - List ids, Map userToIdMap ... +10. 이중적인 의미를 가지는 단어는 지양한다. + - event, design ... +11. 의도가 드러난다면 되도록 짧은 이름을 선택한다. + - retreiveUser() (X) / getUser() (O) + - 단, 축약형을 선택하는 경우는 개발자의 의도가 명백히 전달되는 경우이다. 명백히 전달이 안된다면 축약형보다 서술형이 더 좋다. +12. 함수의 부수효과를 설명한다. + - 함수는 한가지 동작만 수행하는 것이 좋지만, 때에 따라 부수 효과를 일으킬 수도 있다. + ``` + fun getOrder() { + if (order == null) { + order = Order() + } + return order + } + ``` + - 위 함수는 단순히 order만 가져오는 것이 아니라, 없으면 생성해서 리턴한다. + - 그러므로 getOrder() (X) / getOrCreateOrder() (O) +13. LocalDateTime -> xxxAt, LocalDate -> xxxDt로 네이밍 +14. 객체를 조회하는 함수는 JPA Repository에서 findXxx 형식의 네이밍 쿼리메소드를 사용하므로 개발자가 작성하는 Service단에서는 되도록이면 getXxx를 사용하자. diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..d7a7395c --- /dev/null +++ b/build.gradle @@ -0,0 +1,57 @@ +buildscript { + ext { + springBootVersion = "2.7.14" + } + + repositories { + mavenCentral() + } + + dependencies { + classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") + } +} + +subprojects { + group = "sopt.org.umbba" + version = '0.0.1-SNAPSHOT' + apply plugin: "java-library" + apply plugin: "org.springframework.boot" + apply plugin: "io.spring.dependency-management" + + + sourceCompatibility = "11" + + repositories { + mavenCentral() + maven { + url "https://plugins.gradle.org/m2/" + } + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + implementation "org.springframework.boot:spring-boot-starter-validation" + testImplementation "org.springframework.boot:spring-boot-starter-test" + + // lombok + compileOnly "org.projectlombok:lombok" + annotationProcessor "org.projectlombok:lombok" + + // Health Check + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // AWS Secrets Manager + implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap:3.1.3' + implementation 'org.springframework.cloud:spring-cloud-starter-aws-secrets-manager-config:2.2.6.RELEASE' + } + + test { + useJUnitPlatform() + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..249e5832 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..774fae87 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..a69d9cb6 --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..53a6b238 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/umbba-api-dev/appspec.yml b/scripts/umbba-api-dev/appspec.yml new file mode 100644 index 00000000..0386a216 --- /dev/null +++ b/scripts/umbba-api-dev/appspec.yml @@ -0,0 +1,22 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/api-server + overwrite: yes + +permissions: + - object: / + pattern: "**" + owner: ubuntu + group: ubuntu + +hooks: + AfterInstall: + - location: deploy.sh + timeout: 180 + runas: ubuntu + - location: switch.sh + timeout: 180 + runas: ubuntu \ No newline at end of file diff --git a/scripts/umbba-api-dev/deploy.sh b/scripts/umbba-api-dev/deploy.sh new file mode 100644 index 00000000..6905ed35 --- /dev/null +++ b/scripts/umbba-api-dev/deploy.sh @@ -0,0 +1,86 @@ +#!/bin/bash +NOW_TIME="$(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" + +BUILD_PATH=$(ls /home/ubuntu/api-server/umbba-api-0.0.1-SNAPSHOT.jar) +JAR_NAME=$(basename $BUILD_PATH) +echo "[$NOW_TIME] build 파일명: $JAR_NAME" + +echo "[$NOW_TIME] build 파일 복사" +DEPLOY_PATH=/home/ubuntu/api-server/nonstop/jar/ +cp $BUILD_PATH $DEPLOY_PATH + +echo "[$NOW_TIME] 현재 구동중인 Prod 확인" +CURRENT_PROFILE=$(curl -s http://localhost/profile) +echo "[$NOW_TIME] $CURRENT_PROFILE" + +# 쉬고 있는 prod 찾기: dev1이 사용중이면 dev2가 쉬고 있고, 반대면 dev1이 쉬고 있음 +if [ $CURRENT_PROFILE == dev1 ] +then + IDLE_PROFILE=dev2 + IDLE_PORT=8082 +elif [ $CURRENT_PROFILE == dev2 ] +then + IDLE_PROFILE=dev1 + IDLE_PORT=8081 +else + echo "[$NOW_TIME] 일치하는 Profile이 없습니다. Profile: $CURRENT_PROFILE" + echo "[$NOW_TIME] dev1을 할당합니다. IDLE_PROFILE: dev1" + IDLE_PROFILE=dev1 + IDLE_PORT=8081 +fi + +echo "[$NOW_TIME] application.jar 교체" +IDLE_APPLICATION=$IDLE_PROFILE-Umbba-API.jar +IDLE_APPLICATION_PATH=$DEPLOY_PATH$IDLE_APPLICATION + +ln -Tfs $DEPLOY_PATH$JAR_NAME $IDLE_APPLICATION_PATH + +echo "[$NOW_TIME] $IDLE_PROFILE 에서 구동중인 애플리케이션 pid 확인" +IDLE_PID=$(pgrep -f $IDLE_APPLICATION) + +if [ -z $IDLE_PID ] +then + echo "[$NOW_TIME] 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." +else + echo "[$NOW_TIME] kill -15 $IDLE_PID" + kill -15 $IDLE_PID + + while ps -p $IDLE_PID > /dev/null; do + sleep 1 + done + echo "[$NOW_TIME] 애플리케이션이 정상 종료되었습니다." +fi + +echo "[$NOW_TIME] $IDLE_PROFILE 배포" +nohup java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=$IDLE_PROFILE $IDLE_APPLICATION_PATH >> /home/ubuntu/api-server/deploy.log 2>/home/ubuntu/api-server/deploy_err.log & + +################################################################## + +echo "[$NOW_TIME] $IDLE_PROFILE 10초 후 Health check 시작" +echo "[$NOW_TIME] curl -s http://localhost:$IDLE_PORT/health " +sleep 10 + +for retry_count in {1..10} +do + response=$(curl -s http://localhost:$IDLE_PORT/actuator/health) + up_count=$(echo $response | grep 'UP' | wc -l) + + if [ $up_count -ge 1 ] + then # $up_count >= 1 ("UP" 문자열이 있는지 검증) + echo "[$NOW_TIME] Health check 성공" + break + else + echo "[$NOW_TIME] Health check의 응답을 알 수 없거나 혹은 status가 UP이 아닙니다." + echo "[$NOW_TIME] Health check: ${response}" + fi + + if [ $retry_count -eq 10 ] + then + echo "[$NOW_TIME] Health check 실패. " + echo "[$NOW_TIME] Nginx에 연결하지 않고 배포를 종료합니다." + exit 1 + fi + + echo "[$NOW_TIME] Health check 연결 실패. 재시도..." + sleep 10 +done \ No newline at end of file diff --git a/scripts/umbba-api-dev/switch.sh b/scripts/umbba-api-dev/switch.sh new file mode 100644 index 00000000..06387f45 --- /dev/null +++ b/scripts/umbba-api-dev/switch.sh @@ -0,0 +1,30 @@ +#!/bin/bash +NOW_TIME="$(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" + +echo "[$NOW_TIME] 스위칭" +sleep 10 +echo "[$NOW_TIME] 현재 구동중인 Port 확인" +CURRENT_PROFILE=$(curl -s http://localhost/profile) + +# 쉬고 있는 prod 찾기: dev1이 사용중이면 dev2가 쉬고 있고, 반대면 dev1이 쉬고 있음 +if [ $CURRENT_PROFILE == dev1 ] +then + IDLE_PORT=8082 +elif [ $CURRENT_PROFILE == dev2 ] +then + IDLE_PORT=8081 +else + echo "[$NOW_TIME] 일치하는 Profile이 없습니다. Profile: $CURRENT_PROFILE" + echo "[$NOW_TIME] 8081을 할당합니다." + IDLE_PORT=8081 +fi + +echo "[$NOW_TIME] 전환할 Port: $IDLE_PORT" +echo "[$NOW_TIME] Port 전환" +echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc + +PROXY_PORT=$(curl -s http://localhost/profile) +echo "[$NOW_TIME] Nginx Current Proxy Port: $PROXY_PORT" + +echo "[$NOW_TIME] Nginx Reload" +sudo service nginx reload \ No newline at end of file diff --git a/scripts/umbba-api-prod/appspec.yml b/scripts/umbba-api-prod/appspec.yml new file mode 100644 index 00000000..0386a216 --- /dev/null +++ b/scripts/umbba-api-prod/appspec.yml @@ -0,0 +1,22 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/api-server + overwrite: yes + +permissions: + - object: / + pattern: "**" + owner: ubuntu + group: ubuntu + +hooks: + AfterInstall: + - location: deploy.sh + timeout: 180 + runas: ubuntu + - location: switch.sh + timeout: 180 + runas: ubuntu \ No newline at end of file diff --git a/scripts/umbba-api-prod/deploy.sh b/scripts/umbba-api-prod/deploy.sh new file mode 100644 index 00000000..d8680401 --- /dev/null +++ b/scripts/umbba-api-prod/deploy.sh @@ -0,0 +1,86 @@ +#!/bin/bash +NOW_TIME="$(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" + +BUILD_PATH=$(ls /home/ubuntu/api-server/umbba-api-0.0.1-SNAPSHOT.jar) +JAR_NAME=$(basename $BUILD_PATH) +echo "[$NOW_TIME] build 파일명: $JAR_NAME" + +echo "[$NOW_TIME] build 파일 복사" +DEPLOY_PATH=/home/ubuntu/api-server/nonstop/jar/ +cp $BUILD_PATH $DEPLOY_PATH + +echo "[$NOW_TIME] 현재 구동중인 Prod 확인" +CURRENT_PROFILE=$(curl -s http://localhost/profile) +echo "[$NOW_TIME] $CURRENT_PROFILE" + +# 쉬고 있는 prod 찾기: prod1이 사용중이면 prod2가 쉬고 있고, 반대면 prod1이 쉬고 있음 +if [ $CURRENT_PROFILE == prod1 ] +then + IDLE_PROFILE=prod2 + IDLE_PORT=8082 +elif [ $CURRENT_PROFILE == prod2 ] +then + IDLE_PROFILE=prod1 + IDLE_PORT=8081 +else + echo "[$NOW_TIME] 일치하는 Profile이 없습니다. Profile: $CURRENT_PROFILE" + echo "[$NOW_TIME] prod1을 할당합니다. IDLE_PROFILE: prod1" + IDLE_PROFILE=prod1 + IDLE_PORT=8081 +fi + +echo "[$NOW_TIME] application.jar 교체" +IDLE_APPLICATION=$IDLE_PROFILE-Umbba-API.jar +IDLE_APPLICATION_PATH=$DEPLOY_PATH$IDLE_APPLICATION + +ln -Tfs $DEPLOY_PATH$JAR_NAME $IDLE_APPLICATION_PATH + +echo "[$NOW_TIME] $IDLE_PROFILE 에서 구동중인 애플리케이션 pid 확인" +IDLE_PID=$(pgrep -f $IDLE_APPLICATION) + +if [ -z $IDLE_PID ] +then + echo "[$NOW_TIME] 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." +else + echo "[$NOW_TIME] kill -15 $IDLE_PID" + kill -15 $IDLE_PID + + while ps -p $IDLE_PID > /dev/null; do + sleep 1 + done + echo "[$NOW_TIME] 애플리케이션이 정상 종료되었습니다." +fi + +echo "[$NOW_TIME] $IDLE_PROFILE 배포" +nohup java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=$IDLE_PROFILE $IDLE_APPLICATION_PATH >> /home/ubuntu/api-server/deploy.log 2>/home/ubuntu/api-server/deploy_err.log & + +################################################################## + +echo "[$NOW_TIME] $IDLE_PROFILE 10초 후 Health check 시작" +echo "[$NOW_TIME] curl -s http://localhost:$IDLE_PORT/health " +sleep 10 + +for retry_count in {1..10} +do + response=$(curl -s http://localhost:$IDLE_PORT/actuator/health) + up_count=$(echo $response | grep 'UP' | wc -l) + + if [ $up_count -ge 1 ] + then # $up_count >= 1 ("UP" 문자열이 있는지 검증) + echo "[$NOW_TIME] Health check 성공" + break + else + echo "[$NOW_TIME] Health check의 응답을 알 수 없거나 혹은 status가 UP이 아닙니다." + echo "[$NOW_TIME] Health check: ${response}" + fi + + if [ $retry_count -eq 10 ] + then + echo "[$NOW_TIME] Health check 실패. " + echo "[$NOW_TIME] Nginx에 연결하지 않고 배포를 종료합니다." + exit 1 + fi + + echo "[$NOW_TIME] Health check 연결 실패. 재시도..." + sleep 10 +done \ No newline at end of file diff --git a/scripts/umbba-api-prod/switch.sh b/scripts/umbba-api-prod/switch.sh new file mode 100644 index 00000000..4efce738 --- /dev/null +++ b/scripts/umbba-api-prod/switch.sh @@ -0,0 +1,30 @@ +#!/bin/bash +NOW_TIME="$(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" + +echo "[$NOW_TIME] 스위칭" +sleep 10 +echo "[$NOW_TIME] 현재 구동중인 Port 확인" +CURRENT_PROFILE=$(curl -s http://localhost/profile) + +# 쉬고 있는 prod 찾기: prod1이 사용중이면 prod2가 쉬고 있고, 반대면 prod1이 쉬고 있음 +if [ $CURRENT_PROFILE == prod1 ] +then + IDLE_PORT=8082 +elif [ $CURRENT_PROFILE == prod2 ] +then + IDLE_PORT=8081 +else + echo "[$NOW_TIME] 일치하는 Profile이 없습니다. Profile: $CURRENT_PROFILE" + echo "[$NOW_TIME] 8081을 할당합니다." + IDLE_PORT=8081 +fi + +echo "[$NOW_TIME] 전환할 Port: $IDLE_PORT" +echo "[$NOW_TIME] Port 전환" +echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc + +PROXY_PORT=$(curl -s http://localhost/profile) +echo "[$NOW_TIME] Nginx Current Proxy Port: $PROXY_PORT" + +echo "[$NOW_TIME] Nginx Reload" +sudo service nginx reload \ No newline at end of file diff --git a/scripts/umbba-notification-dev/appspec.yml b/scripts/umbba-notification-dev/appspec.yml new file mode 100644 index 00000000..4a0bf554 --- /dev/null +++ b/scripts/umbba-notification-dev/appspec.yml @@ -0,0 +1,19 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/notification-server + overwrite: yes + +permissions: + - object: / + pattern: "**" + owner: ubuntu + group: ubuntu + +hooks: + AfterInstall: + - location: deploy.sh + timeout: 180 + runas: ubuntu \ No newline at end of file diff --git a/scripts/umbba-notification-dev/deploy.sh b/scripts/umbba-notification-dev/deploy.sh new file mode 100644 index 00000000..8024a227 --- /dev/null +++ b/scripts/umbba-notification-dev/deploy.sh @@ -0,0 +1,19 @@ +#!/bin/bash +NOW_TIME="$(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" + +BUILD_PATH=/home/ubuntu/notification-server/umbba-notification-0.0.1-SNAPSHOT.jar +TARGET_PORT=8083 +TARGET_PID=$(lsof -Fp -i TCP:${TARGET_PORT} | grep -Po 'p[0-9]+' | grep -Po '[0-9]+') + +if [ ! -z ${TARGET_PID} ]; then + echo "[$NOW_TIME] Kill WAS running at ${TARGET_PORT}." >> /home/ubuntu/notification-server/deploy.log + sudo kill -15 ${TARGET_PID} + while ps -p $TARGET_PID > /dev/null; do + sleep 1 + done + echo "[$NOW_TIME] 애플리케이션이 정상 종료되었습니다." +fi + +nohup java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=dev $BUILD_PATH >> /home/ubuntu/notification-server/deploy.log 2>/home/ubuntu/notification-server/deploy_err.log & +echo "[$NOW_TIME] Now new WAS runs at ${TARGET_PORT}." >> /home/ubuntu/notification-server/deploy.log +exit 0 \ No newline at end of file diff --git a/scripts/umbba-notification-prod/appspec.yml b/scripts/umbba-notification-prod/appspec.yml new file mode 100644 index 00000000..4a0bf554 --- /dev/null +++ b/scripts/umbba-notification-prod/appspec.yml @@ -0,0 +1,19 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/notification-server + overwrite: yes + +permissions: + - object: / + pattern: "**" + owner: ubuntu + group: ubuntu + +hooks: + AfterInstall: + - location: deploy.sh + timeout: 180 + runas: ubuntu \ No newline at end of file diff --git a/scripts/umbba-notification-prod/deploy.sh b/scripts/umbba-notification-prod/deploy.sh new file mode 100644 index 00000000..1d95de1b --- /dev/null +++ b/scripts/umbba-notification-prod/deploy.sh @@ -0,0 +1,19 @@ +#!/bin/bash +NOW_TIME="$(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" + +BUILD_PATH=/home/ubuntu/notification-server/umbba-notification-0.0.1-SNAPSHOT.jar +TARGET_PORT=8083 +TARGET_PID=$(lsof -Fp -i TCP:${TARGET_PORT} | grep -Po 'p[0-9]+' | grep -Po '[0-9]+') + +if [ ! -z ${TARGET_PID} ]; then + echo "[$NOW_TIME] Kill WAS running at ${TARGET_PORT}." >> /home/ubuntu/notification-server/deploy.log + sudo kill -15 ${TARGET_PID} + while ps -p $TARGET_PID > /dev/null; do + sleep 1 + done + echo "[$NOW_TIME] 애플리케이션이 정상 종료되었습니다." +fi + +nohup java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod $BUILD_PATH >> /home/ubuntu/notification-server/deploy.log 2>/home/ubuntu/notification-server/deploy_err.log & +echo "[$NOW_TIME] Now new WAS runs at ${TARGET_PORT}." >> /home/ubuntu/notification-server/deploy.log +exit 0 \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..12ede70b --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +rootProject.name = 'umbbaServer' + +include 'umbba-common' +include 'umbba-domain' +include 'umbba-external' +include 'umbba-api' +include 'umbba-notification' + diff --git a/umbba-api/build.gradle b/umbba-api/build.gradle new file mode 100644 index 00000000..42904e80 --- /dev/null +++ b/umbba-api/build.gradle @@ -0,0 +1,43 @@ +jar { enabled = false } + +dependencies { + implementation project(':umbba-common') + implementation project(':umbba-domain') + implementation project(':umbba-external') + + // spring mvc + implementation 'org.springframework.boot:spring-boot-starter-web' + + // swagger +// implementation 'org.springdoc:springdoc-openapi-ui:1.5.4' + + // SQS + implementation "org.springframework.cloud:spring-cloud-aws-messaging:2.2.6.RELEASE" + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' + + // jwt + implementation group: "io.jsonwebtoken", name: "jjwt-api", version: "0.11.2" + implementation group: "io.jsonwebtoken", name: "jjwt-impl", version: "0.11.2" + implementation group: "io.jsonwebtoken", name: "jjwt-jackson", version: "0.11.2" + + // random String + implementation 'org.apache.commons:commons-lang3' + + // for FeignException + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:3.1.7' + + // redis + implementation "org.springframework.boot:spring-boot-starter-data-redis" + implementation "org.springframework.session:spring-session-data-redis" + + // s3 +// implementation "org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE" + + // AWS Secrets Manager + implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap:3.1.3' + implementation 'org.springframework.cloud:spring-cloud-starter-aws-secrets-manager-config:2.2.6.RELEASE' +} + diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/ApiApplication.java b/umbba-api/src/main/java/sopt/org/umbba/api/ApiApplication.java new file mode 100644 index 00000000..97943392 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/ApiApplication.java @@ -0,0 +1,25 @@ +package sopt.org.umbba.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; +import sopt.org.umbba.common.UmbbaCommonRoot; +import sopt.org.umbba.domain.UmbbaDomainRoot; +import sopt.org.umbba.external.UmbbaExternalRoot; + +@SpringBootApplication(scanBasePackageClasses = { + UmbbaCommonRoot.class, + UmbbaDomainRoot.class, + UmbbaExternalRoot.class, + ApiApplication.class +}, exclude = { UserDetailsServiceAutoConfiguration.class }) +@EnableFeignClients(basePackageClasses = UmbbaExternalRoot.class) +public class ApiApplication { + + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } + +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/SecurityConfig.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/SecurityConfig.java new file mode 100644 index 00000000..9f3c1bed --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/SecurityConfig.java @@ -0,0 +1,51 @@ +package sopt.org.umbba.api.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import sopt.org.umbba.api.config.auth.CustomJwtAuthenticationEntryPoint; +import sopt.org.umbba.api.config.auth.JwtAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; + + private static final String[] AUTH_WHITELIST = { + "/kakao/**", "/login", "/reissue", + "/qna/**", "onboard/**", "/home", "/dummy", "/user/me", +// "/log-out", + "/test", "/profile", "/health", "/actuator/health", + "/alarm/qna", "/alarm/drink", + "/demo/**", + "/album/image" + }; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .formLogin().disable() // Form Login 사용 X + .httpBasic().disable() // HTTP Basic 사용 X + .csrf().disable() // 쿠키 기반이 아닌 JWT 기반이므로 사용 X + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // Spring Security 세션 정책 : 세션을 생성 및 사용하지 않음 + .and() + .exceptionHandling() + .authenticationEntryPoint(customJwtAuthenticationEntryPoint) // 에러 핸들링 + .and() + .authorizeHttpRequests() + .antMatchers(AUTH_WHITELIST).permitAll() + .anyRequest().authenticated() + .and() + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // JWT 인증 필터 적용 + .build(); + } + +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/SqsConfig.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/SqsConfig.java new file mode 100644 index 00000000..9ff1380b --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/SqsConfig.java @@ -0,0 +1,33 @@ +package sopt.org.umbba.api.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.sqs.AmazonSQSAsync; +import com.amazonaws.services.sqs.AmazonSQSAsyncClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class SqsConfig { + + @Value("${cloud.aws.credentials.accessKey}") + private String AWS_ACCESS_KEY; + + @Value("${cloud.aws.credentials.secretKey}") + private String AWS_SECRET_KEY; + + @Value("${cloud.aws.region.static}") + private String AWS_REGION; + + @Primary + @Bean + public AmazonSQSAsync amazonSQSAsync() { + BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(AWS_ACCESS_KEY, AWS_SECRET_KEY); + return AmazonSQSAsyncClientBuilder.standard() + .withRegion(AWS_REGION) + .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials)) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/CustomJwtAuthenticationEntryPoint.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/CustomJwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..39a23c4c --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/CustomJwtAuthenticationEntryPoint.java @@ -0,0 +1,30 @@ +package sopt.org.umbba.api.config.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.dto.ApiResponse; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + setResponse(response); + } + + private void setResponse(HttpServletResponse response) throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().println(objectMapper.writeValueAsString(ApiResponse.error(ErrorType.INVALID_ACCESS_TOKEN))); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/JwtAuthenticationFilter.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/JwtAuthenticationFilter.java new file mode 100644 index 00000000..99951680 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package sopt.org.umbba.api.config.auth; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import sopt.org.umbba.api.config.jwt.JwtProvider; +import sopt.org.umbba.api.config.jwt.JwtValidationType; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * JWT가 유효성을 검증하는 Filter + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws IOException, ServletException { + try { + + // Request의 Header에서 JWT 토큰을 String으로 가져옴 + final String token = getJwtFromRequest(request); + + if (StringUtils.hasText(token) && jwtProvider.validateAccessToken(token) == JwtValidationType.VALID_JWT) { + Long userId = jwtProvider.getUserFromJwt(token); + UserAuthentication authentication = new UserAuthentication(userId, null, null); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception exception) { + log.error("error : ", exception); + } + + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring("Bearer ".length()); + } + return null; + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/UserAuthentication.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/UserAuthentication.java new file mode 100644 index 00000000..f0f7083c --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/auth/UserAuthentication.java @@ -0,0 +1,13 @@ +package sopt.org.umbba.api.config.auth; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + + public UserAuthentication(Object principal, Object credentials, Collection authorities) { + super(principal, credentials, authorities); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/JwtProvider.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/JwtProvider.java new file mode 100644 index 00000000..77482664 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/JwtProvider.java @@ -0,0 +1,159 @@ +package sopt.org.umbba.api.config.jwt; + + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import sopt.org.umbba.domain.domain.redis.RefreshToken; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; + +import javax.annotation.PostConstruct; +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.Base64; +import java.util.Date; + +import static java.util.Objects.isNull; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtProvider { + + + private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 60 * 1000L * 60 * 24 * 365; // 액세스 토큰 만료 시간: 1일로 지정 + private static final Long REFRESH_TOKEN_EXPIRATION_TIME = 60 * 1000L * 180 * 10; // 리프레시 토큰 만료 시간: 30시간으로 지정 + + @Value("${jwt.secret}") + private String JWT_SECRET; + private final TokenRepository tokenRepository; + + @PostConstruct + protected void init() { + JWT_SECRET = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes(StandardCharsets.UTF_8)); + } + + + public TokenDto issueToken(Authentication authentication) { + return TokenDto.of( + generateAccessToken(authentication), + generateRefreshToken(authentication)); + } + + // Access 토큰 생성 + private String generateAccessToken(Authentication authentication) { + final Date now = new Date(); + + final Claims claims = Jwts.claims() + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION_TIME)); + + claims.put("userId", authentication.getPrincipal()); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(claims) + .signWith(getSigningKey()) + .compact(); + } + + // Refresh 토큰 생성 + /** + * Redis 내부에는 + * refreshToken:userId : tokenValue 형태로 저장한다. + * accessToken과 다르게 UUID로 생성한다. + */ + private String generateRefreshToken(Authentication authentication) { + final Date now = new Date(); + + final Claims claims = Jwts.claims() + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME)); + + // TODO UUID or JWT 토큰으로 암호화하여 생성 + String refreshToken = Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(claims) + .signWith(getSigningKey()) + .compact(); + + tokenRepository.save( + RefreshToken.builder() + .id(Long.parseLong(authentication.getPrincipal().toString())) + .refreshToken(refreshToken) + .expiration(REFRESH_TOKEN_EXPIRATION_TIME.intValue() / 1000) + .build() + ); + + return refreshToken; + } + + // Access 토큰 검증 + public JwtValidationType validateAccessToken(String accessToken) { + try { + final Claims claims = getBody(accessToken); + log.info(JwtValidationType.VALID_JWT.getValue()); + return JwtValidationType.VALID_JWT; + } catch (MalformedJwtException ex) { + log.error(JwtValidationType.INVALID_JWT_TOKEN.getValue()); + return JwtValidationType.INVALID_JWT_TOKEN; + } catch (ExpiredJwtException ex) { + log.error(JwtValidationType.EXPIRED_JWT_TOKEN.getValue()); + return JwtValidationType.EXPIRED_JWT_TOKEN; + } catch (UnsupportedJwtException ex) { + log.error(JwtValidationType.UNSUPPORTED_JWT_TOKEN.getValue()); + return JwtValidationType.UNSUPPORTED_JWT_TOKEN; + } catch (IllegalArgumentException ex) { + log.error(JwtValidationType.EMPTY_JWT.getValue()); + return JwtValidationType.EMPTY_JWT; + } + } + + // Refresh 토큰 검증 + public boolean validateRefreshToken(Long userId, String refreshToken) throws Exception { + // 해당유저의 Refresh 토큰 만료 : Redis에 해당 유저의 토큰이 존재하지 않음 + RefreshToken token = tokenRepository.findById(userId).orElseThrow(() -> new CustomException(ErrorType.INVALID_REFRESH_TOKEN)); + + if (token.getRefreshToken() == null) { + return false; + } + else return token.getRefreshToken().equals(refreshToken); + } + + public void deleteRefreshToken(Long userId) { + tokenRepository.deleteById(userId); + } + + // 토큰에 담겨있는 userId 획득 + public Long getUserFromJwt(String token) { + Claims claims = getBody(token); + return Long.parseLong(claims.get("userId").toString()); + } + + private Claims getBody(final String token) { + // 만료된 토큰에 대해 parseClaimsJws를 수행하면 io.jsonwebtoken.ExpiredJwtException이 발생한다. + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private SecretKey getSigningKey() { + String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes()); + return Keys.hmacShaKeyFor(encodedKey.getBytes()); + } + + public static Long getUserFromPrincial(Principal principal) { + if (isNull(principal)) { + throw new CustomException(ErrorType.EMPTY_PRINCIPLE_EXCEPTION); + } + return Long.valueOf(principal.getName()); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/JwtValidationType.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/JwtValidationType.java new file mode 100644 index 00000000..dd7cc444 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/JwtValidationType.java @@ -0,0 +1,19 @@ +package sopt.org.umbba.api.config.jwt; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum JwtValidationType { + + VALID_JWT("유효한 JWT 토큰입니다."), + INVALID_JWT_SIGNATURE("잘못된 JWT 서명입니다."), + INVALID_JWT_TOKEN("유효하지 않는 JWT 토큰입니다."), + EXPIRED_JWT_TOKEN("만료된 JWT 토큰입니다."), + UNSUPPORTED_JWT_TOKEN("지원하지 않는 JWT 토큰입니다."), + EMPTY_JWT("JWT 토큰이 존재하지 않습니다."); + + private final String value; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/TokenDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/TokenDto.java new file mode 100644 index 00000000..4c456b26 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/TokenDto.java @@ -0,0 +1,21 @@ +package sopt.org.umbba.api.config.jwt; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TokenDto { + + private String accessToken; + private String refreshToken; + + public static TokenDto of(String accessToken, String refreshToken) { + return new TokenDto(accessToken, refreshToken); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/TokenRepository.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/TokenRepository.java new file mode 100644 index 00000000..4de694a1 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/jwt/TokenRepository.java @@ -0,0 +1,8 @@ +package sopt.org.umbba.api.config.jwt; + +import org.springframework.data.repository.CrudRepository; +import sopt.org.umbba.domain.domain.redis.RefreshToken; + +//Redis에 저장해주는 역할 +public interface TokenRepository extends CrudRepository { +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/sqs/producer/SqsProducer.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/sqs/producer/SqsProducer.java new file mode 100644 index 00000000..d3595d78 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/sqs/producer/SqsProducer.java @@ -0,0 +1,86 @@ +package sopt.org.umbba.api.config.sqs.producer; + +import com.amazonaws.services.sqs.AmazonSQS; +import com.amazonaws.services.sqs.model.MessageAttributeValue; +import com.amazonaws.services.sqs.model.SendMessageRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import sopt.org.umbba.common.sqs.MessageType; +import sopt.org.umbba.common.sqs.MessageUtils; +import sopt.org.umbba.common.sqs.dto.MessageDto; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * 큐에 메시지를 보내는 역할: API 서버에서 이벤트가 발생할 떄 푸시알림 전송 + * -> 처음 SQS 대기열 생성에서 설정해둔 사항이 여기서 적용 (지연시간, 메시지 수신 대기 등) + * + * 1. 처리할 작업 메시지를 SQS에 등록 + * 2. 큐에서 메시지를 소비(consume)하는 것을 실패한 경우, DLQ로 전송 + * + * TODO 기존에 푸시알림을 파이어베이스로 보내기 위해 호출했던 함수를 SQS Producer로 대체 + */ +@Slf4j +@Component +public class SqsProducer { + + @Value("${cloud.aws.sqs.notification.url}") + private String NOTIFICATION_URL; + + private static final String GROUP_ID = "sqs"; + private final ObjectMapper objectMapper; + private final AmazonSQS amazonSqs; + private static final String SQS_QUEUE_REQUEST_LOG_MESSAGE = "====> [SQS Queue Request] : %s "; + + public SqsProducer(ObjectMapper objectMapper, AmazonSQS amazonSqs) { + this.objectMapper = objectMapper; + this.amazonSqs = amazonSqs; + } + + + public void produce(MessageDto message) { + try { + SendMessageRequest request = new SendMessageRequest(NOTIFICATION_URL, + objectMapper.writeValueAsString(message)) +// .withMessageGroupId(GROUP_ID) +// .withMessageDeduplicationId(UUID.randomUUID().toString()) // TODO UUID Random String으로 변경 + .withMessageAttributes(createMessageAttributes(message.getType())); + + amazonSqs.sendMessage(request); + if (Objects.equals(message.getType(), MessageType.SLACK)) { + log.info(MessageUtils.generate(SQS_QUEUE_REQUEST_LOG_MESSAGE, "Slack 500 Error 내용")); + } else { + log.info(MessageUtils.generate(SQS_QUEUE_REQUEST_LOG_MESSAGE, request)); + } + + + } catch (JsonProcessingException e) { + log.error(e.getMessage(), e); + } + } + + private Map createMessageAttributes(String type) { + + return Map.of(MessageType.MESSAGE_TYPE_HEADER, new MessageAttributeValue() + .withDataType("String") + .withStringValue(type)); + } + + + /* Queue에 단일 메시지를 보내는 함수 -> SQS 실습에서 사용한 함수 + public SendResult sendMessage(String groupId, String message) { +// Message newMessage = MessageBuilder.withPayload(message).build(); + System.out.println("Sender: " + message); + return queueMessagingTemplate.send(to -> to + .queue(QUEUE_NAME) + .messageGroupId(groupId) + .messageDeduplicationId(groupId) + .payload(message)); + } + */ +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/advice/ControllerExceptionAdvice.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/advice/ControllerExceptionAdvice.java new file mode 100644 index 00000000..21fff53d --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/advice/ControllerExceptionAdvice.java @@ -0,0 +1,205 @@ +package sopt.org.umbba.api.controller.advice; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.util.NestedServletException; +import sopt.org.umbba.api.service.notification.NotificationService; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.dto.ApiResponse; +import sopt.org.umbba.common.exception.model.CustomException; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.UnexpectedTypeException; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; + +@Slf4j +@RestControllerAdvice +@Component +@RequiredArgsConstructor +public class ControllerExceptionAdvice { + + private final NotificationService notificationService; + + /** + * 400 BAD_REQUEST + */ + + // FeignException은 @ControllerAdvice에서 처리하는 것이 권장되지 않음 + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ApiResponse handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) { + + Errors errors = e.getBindingResult(); + Map validateDetails = new HashMap<>(); + + for (FieldError error : errors.getFieldErrors()) { + String validKeyName = String.format("valid_%s", error.getField()); + validateDetails.put(validKeyName, error.getDefaultMessage()); + } + return ApiResponse.error(ErrorType.REQUEST_VALIDATION_EXCEPTION, validateDetails); + } + + // 잘못된 타입으로 요청을 보낸 경우 + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(UnexpectedTypeException.class) + protected ApiResponse handleUnexpectedTypeException(final UnexpectedTypeException e) { + return ApiResponse.error(ErrorType.VALIDATION_WRONG_TYPE_EXCEPTION); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ApiResponse handlerMethodArgumentTypeMismatchException(final MethodArgumentTypeMismatchException e) { + return ApiResponse.error(ErrorType.VALIDATION_WRONG_TYPE_EXCEPTION); + } + + // Header에 원하는 Key가 없는 경우 + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MissingRequestHeaderException.class) + protected ApiResponse handlerMissingRequestHeaderException(final MissingRequestHeaderException e) { + return ApiResponse.error(ErrorType.HEADER_REQUEST_MISSING_EXCEPTION); + } + + // Enum 값에 존재하지 않는 request가 입력되었을 때 + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ApiResponse handlerHttpMessageNotReadableException(final HttpMessageNotReadableException e) { + return ApiResponse.error(ErrorType.VALIDATION_WRONG_HTTP_REQUEST); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ApiResponse handlerHttpRequestMethodNotSupportedException(final HttpRequestMethodNotSupportedException e) { + return ApiResponse.error(ErrorType.INVALID_HTTP_METHOD); + } + + + /** + * 500 INTERNEL_SERVER // TODO 서비스 단에서 예외가 꼼꼼하게 처리된 상태에서 500 에러를 가장 마지막에 던지도록 처리 + */ + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + protected ApiResponse handleException(final Exception e, final HttpServletRequest request) throws IOException { + log.error("Unexpected exception occurred: {}", e.getMessage(), e); + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(IllegalArgumentException.class) + public ApiResponse handlerIllegalArgumentException(final IllegalArgumentException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(IOException.class) + public ApiResponse handlerIOException(final IOException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(RuntimeException.class) + public ApiResponse handlerRuntimeException(final RuntimeException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR, e); + } + + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(IndexOutOfBoundsException.class) + protected ApiResponse handlerIndexOutOfBoundsException(final IndexOutOfBoundsException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.INDEX_OUT_OF_BOUNDS, e); + } + + /*@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(UnknownClassException.class) + protected ApiResponse handlerUnknownClassException(final UnknownClassException e) { + return ApiResponse.error(ErrorType.JWT_SERIALIZE, e); + }*/ + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(NoSuchElementException.class) + protected ApiResponse handlerNoSuchElementException(final NoSuchElementException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.OPTIONAL_EMPTY, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(IncorrectResultSizeDataAccessException.class) + protected ApiResponse handlerIncorrectResultSizeDataAccessException(final IncorrectResultSizeDataAccessException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.NON_UNIQUE_RESULT_OF_QUERY, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(NestedServletException.class) + public ApiResponse handlerNestedServletException(final NestedServletException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.INTERNAL_SERVLET_ERROR, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(InvalidDataAccessApiUsageException.class) + public ApiResponse handlerInvalidDataAccessApiUsageException(final InvalidDataAccessApiUsageException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.NO_ENUM_TYPE, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(DataIntegrityViolationException.class) + public ApiResponse handlerDataIntegrityViolationException(final DataIntegrityViolationException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.DATA_INTEGRITY_ERROR, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(JpaSystemException.class) + public ApiResponse handlerJpaSystemException(final JpaSystemException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.DATABASE_ERROR, e); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(NullPointerException.class) + public ApiResponse handlerNullPointerException(final NullPointerException e, final HttpServletRequest request) { + notificationService.sendExceptionToSlack(e, request); + return ApiResponse.error(ErrorType.NULL_POINTER_ERROR, e); + } + + /** + * CUSTOM_ERROR + */ + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(CustomException e, final HttpServletRequest request) { + + log.error("CustomException occured: {}", e.getMessage(), e); + + if (e.getHttpStatus() == 501) { + notificationService.sendExceptionToSlack(e, request); + } + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.error(e.getErrorType(), e.getMessage())); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/AlbumController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/AlbumController.java new file mode 100644 index 00000000..d0f46a1f --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/AlbumController.java @@ -0,0 +1,75 @@ +package sopt.org.umbba.api.controller.album; + +import static sopt.org.umbba.api.config.jwt.JwtProvider.*; +import static sopt.org.umbba.api.service.album.AlbumService.*; +import static sopt.org.umbba.common.exception.SuccessType.*; +import static sopt.org.umbba.external.s3.S3BucketPrefix.*; + +import java.security.Principal; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.api.controller.album.dto.request.AlbumImgUrlRequestDto; +import sopt.org.umbba.api.controller.album.dto.request.CreateAlbumRequestDto; +import sopt.org.umbba.api.controller.album.dto.response.AlbumResponseDto; +import sopt.org.umbba.api.service.album.AlbumService; +import sopt.org.umbba.common.exception.dto.ApiResponse; +import sopt.org.umbba.external.s3.PreSignedUrlDto; +import sopt.org.umbba.external.s3.S3BucketPrefix; +import sopt.org.umbba.external.s3.S3Service; + +@RestController +@RequestMapping("/album") +@RequiredArgsConstructor +public class AlbumController { + + private final AlbumService albumService; + private final S3Service s3Service; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createAlbum(@Valid @RequestBody final CreateAlbumRequestDto request, final Principal principal, HttpServletResponse response) { + String imgUrl = s3Service.getS3ImgUrl(ALBUM_PREFIX.getValue(), request.getImgFileName()); + Long albumId = albumService.createAlbum(request, imgUrl, getUserFromPrincial(principal)); + response.setHeader("Location", "/album/" + albumId); + return ApiResponse.success(CREATE_ALBUM_SUCCESS); + } + + // PreSigned Url 이용 (클라이언트에서 해당 URL로 업로드) + @PatchMapping("/image") + @ResponseStatus(HttpStatus.OK) + public ApiResponse getImgPreSignedUrl(@RequestBody final AlbumImgUrlRequestDto request) { + return ApiResponse.success(GET_PRE_SIGNED_URL_SUCCESS, s3Service.getPreSignedUrl(S3BucketPrefix.of(request.getImgPrefix()))); + } + + @DeleteMapping("/{albumId}") + @ResponseStatus(HttpStatus.OK) + public ApiResponse deleteAlbum(@PathVariable final Long albumId, final Principal principal) { + String imgUrl = albumService.deleteAlbum(albumId, getUserFromPrincial(principal)); + if (!imgUrl.equals(ALBUM_EXAMPLE)) { // Example Album의 이미지는 삭제하지 X + s3Service.deleteS3Image(imgUrl); + } + return ApiResponse.success(DELETE_ALBUM_SUCCESS); + } + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse> getAlbumList(final Principal principal) { + return ApiResponse.success(GET_ALBUM_LIST_SUCCESS, albumService.getAlbumList(getUserFromPrincial(principal))); + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/AlbumImgUrlRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/AlbumImgUrlRequestDto.java new file mode 100644 index 00000000..669e7126 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/AlbumImgUrlRequestDto.java @@ -0,0 +1,16 @@ +package sopt.org.umbba.api.controller.album.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class AlbumImgUrlRequestDto { + + private String imgPrefix; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/CreateAlbumRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/CreateAlbumRequestDto.java new file mode 100644 index 00000000..5b25abfb --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/CreateAlbumRequestDto.java @@ -0,0 +1,28 @@ +package sopt.org.umbba.api.controller.album.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class CreateAlbumRequestDto { + + @NotBlank(message = "제목은 필수 입력 값입니다.") + @Size(max = 15) + private String title; + + @NotBlank(message = "소개글은 필수 입력 값입니다.") + @Size(max = 32) + private String content; + + @NotBlank(message = "이미지 파일명은 필수 입력 값입니다.") + private String imgFileName; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/response/AlbumResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/response/AlbumResponseDto.java new file mode 100644 index 00000000..96789566 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/response/AlbumResponseDto.java @@ -0,0 +1,30 @@ +package sopt.org.umbba.api.controller.album.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.album.Album; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class AlbumResponseDto { + + private Long albumId; + private String title; + private String content; + private String writer; + private String imgUrl; + + public static AlbumResponseDto of(Album album) { + return AlbumResponseDto.builder() + .albumId(album.getId()) + .title(album.getTitle()) + .content(album.getContent()) + .writer(album.getWriter()) + .imgUrl(album.getImgUrl()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/CloserController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/CloserController.java new file mode 100644 index 00000000..c8ceffa0 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/CloserController.java @@ -0,0 +1,47 @@ +package sopt.org.umbba.api.controller.closer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import sopt.org.umbba.api.config.jwt.JwtProvider; +import sopt.org.umbba.api.controller.closer.dto.request.TodayCloserAnswerRequestDto; +import sopt.org.umbba.api.controller.closer.dto.response.TodayCloserQnAResponseDto; +import sopt.org.umbba.api.service.closer.CloserService; +import sopt.org.umbba.common.exception.SuccessType; +import sopt.org.umbba.common.exception.dto.ApiResponse; + +import javax.validation.Valid; +import java.security.Principal; + +import static sopt.org.umbba.common.exception.SuccessType.ANSWER_TODAY_CLOSER_QUESTION_SUCCESS; +import static sopt.org.umbba.common.exception.SuccessType.PASS_TO_NEXT_CLOSER_QUESTION_SUCCESS; + +@Slf4j +@RestController +@RequestMapping("/closer") +@RequiredArgsConstructor +public class CloserController { + + private final CloserService closerService; + + @GetMapping("/today") + @ResponseStatus(HttpStatus.OK) + public ApiResponse getTodayCloserQnA(Principal principal) { + return ApiResponse.success(SuccessType.GET_TODAY_CLOSER_QNA_SUCCESS, closerService.getTodayCloserQnA(JwtProvider.getUserFromPrincial(principal))); + } + + @PatchMapping("/answer") + @ResponseStatus(HttpStatus.OK) + public ApiResponse answerTodayCloserQnA(Principal principal, @Valid @RequestBody final TodayCloserAnswerRequestDto request) { + closerService.answerTodayCloserQnA(JwtProvider.getUserFromPrincial(principal), request); + return ApiResponse.success(ANSWER_TODAY_CLOSER_QUESTION_SUCCESS); + } + + @PatchMapping("/next") + @ResponseStatus(HttpStatus.OK) + public ApiResponse passToNextCloserQnA(Principal principal) { + closerService.passToNextCloserQnA(JwtProvider.getUserFromPrincial(principal)); + return ApiResponse.success(PASS_TO_NEXT_CLOSER_QUESTION_SUCCESS); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/dto/request/TodayCloserAnswerRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/dto/request/TodayCloserAnswerRequestDto.java new file mode 100644 index 00000000..aee7c3fc --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/dto/request/TodayCloserAnswerRequestDto.java @@ -0,0 +1,21 @@ +package sopt.org.umbba.api.controller.closer.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TodayCloserAnswerRequestDto { + + @Min(value = 1, message = "답변은 1 혹은 2여야 합니다.") + @Max(value = 2, message = "답변은 1 혹은 2여야 합니다.") + int answer; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/dto/response/TodayCloserQnAResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/dto/response/TodayCloserQnAResponseDto.java new file mode 100644 index 00000000..c0203c03 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/closer/dto/response/TodayCloserQnAResponseDto.java @@ -0,0 +1,73 @@ +package sopt.org.umbba.api.controller.closer.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.closer.CloserQnA; +import sopt.org.umbba.domain.domain.closer.CloserQuestion; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TodayCloserQnAResponseDto { + + private Long closerQnaId; + + private int responseCase; + + private String balanceQuestion; + private String choiceAnswer1; + private String choiceAnswer2; + + private String myChoice; + private String opponentChoice; + + private String imgUrl; + + public static TodayCloserQnAResponseDto of(CloserQnA closerQna, int responseCase, boolean isMeChild) { + + CloserQuestion closerQuestion = closerQna.getCloserQuestion(); + int myAnswer; + int opponentAnswer; + if (isMeChild) { + myAnswer = closerQna.getChildAnswer(); + opponentAnswer = closerQna.getParentAnswer(); + } else { + myAnswer = closerQna.getParentAnswer(); + opponentAnswer = closerQna.getChildAnswer(); + } + + String myChoice; + if (myAnswer == 0) { + myChoice = null; + } else if (myAnswer == 1) { + myChoice = closerQuestion.getChoiceAnswer1(); + } else { + myChoice = closerQuestion.getChoiceAnswer2(); + } + String opponentChoice; + if (opponentAnswer == 0) { + opponentChoice = null; + } else if (opponentAnswer == 1) { + opponentChoice = closerQuestion.getChoiceAnswer1(); + } else { + opponentChoice = closerQuestion.getChoiceAnswer2(); + } + + if (responseCase == 3 && (myAnswer != opponentAnswer)) { + responseCase = 4; + } + + return TodayCloserQnAResponseDto.builder() + .closerQnaId(closerQna.getId()) + .responseCase(responseCase) + .balanceQuestion(closerQuestion.getBalanceQuestion()) + .choiceAnswer1(closerQuestion.getChoiceAnswer1()) + .choiceAnswer2(closerQuestion.getChoiceAnswer2()) + .myChoice(myChoice) + .opponentChoice(opponentChoice) + .imgUrl(closerQuestion.getImgUrl()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/DemoController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/DemoController.java new file mode 100644 index 00000000..c278c058 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/DemoController.java @@ -0,0 +1,59 @@ +package sopt.org.umbba.api.controller.health; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import sopt.org.umbba.api.config.sqs.producer.SqsProducer; +import sopt.org.umbba.api.service.notification.NotificationService; +import sopt.org.umbba.api.service.qna.QnAService; +import sopt.org.umbba.common.exception.SuccessType; +import sopt.org.umbba.common.exception.dto.ApiResponse; +import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto; + +@RestController +@RequiredArgsConstructor +public class DemoController { + + private final QnAService qnAService; + private final NotificationService notificationService; + + /** + * 데모데이 테스트용 QnA리스트 세팅 API + * - API 호출 시 4일차까지 일일문답을 완료했고, 5일차 답변을 할 차례로 만들기 + * - 5일차로 변경되는 시점에서 푸시메시지 전송 + * - 4일차까지의 is**Answer, **Answer 필드가 채워진 상태 + * + * - 최대한 User 테이블만 보고 테스트할 수 있도록 설계 + */ + @PatchMapping("/demo/list/{userId}") + @ResponseStatus(HttpStatus.OK) + public ApiResponse demoList(@PathVariable final Long userId) { + + qnAService.updateDemoList(userId); + return ApiResponse.success(SuccessType.TEST_SUCCESS); + } + + + /** + * 데모데이 테스트용 QnA 세팅 API + * - API 호출할 때마다 일수가 증가하며 새로운 질문으로 업데이트 + * - 오늘의 질문 알림 푸시 함께 전송 + * + */ + @PatchMapping("/demo/qna/{userId}") + @ResponseStatus(HttpStatus.OK) + public ApiResponse demoQnA(@PathVariable final Long userId) { + + qnAService.todayUpdate(userId); + return ApiResponse.success(SuccessType.TEST_SUCCESS); + } + + @PatchMapping("/demo/qna/alarm/{userId}") + @ResponseStatus(HttpStatus.OK) + public ApiResponse qnaAnswerAlarm(@PathVariable final Long userId, @RequestBody String question) { + + notificationService.pushOpponentReply(question, userId); + return ApiResponse.success(SuccessType.TEST_SUCCESS); + } + +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/ServerProfileController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/ServerProfileController.java new file mode 100644 index 00000000..b0f7dc9f --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/ServerProfileController.java @@ -0,0 +1,22 @@ +package sopt.org.umbba.api.controller.health; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; + +@RestController +@RequiredArgsConstructor +public class ServerProfileController { + + private final Environment env; + + @GetMapping("/profile") + public String getProfile() { + return Arrays.stream(env.getActiveProfiles()) + .findFirst() + .orElse(""); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/SlackTestController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/SlackTestController.java new file mode 100644 index 00000000..516dc45b --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/health/SlackTestController.java @@ -0,0 +1,22 @@ +package sopt.org.umbba.api.controller.health; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import sopt.org.umbba.common.exception.dto.ApiResponse; + +@Slf4j +@RestController +@RequestMapping("/test") +public class SlackTestController { + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse test() { + log.info("[test] API 실행"); + throw new IllegalArgumentException(); + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/ParentchildController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/ParentchildController.java new file mode 100644 index 00000000..593963d9 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/ParentchildController.java @@ -0,0 +1,61 @@ +package sopt.org.umbba.api.controller.parentchild; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import sopt.org.umbba.api.config.jwt.JwtProvider; +import sopt.org.umbba.api.controller.parentchild.dto.request.InviteCodeRequestDto; +import sopt.org.umbba.api.controller.parentchild.dto.request.OnboardingInviteRequestDto; +import sopt.org.umbba.api.controller.parentchild.dto.request.OnboardingReceiveRequestDto; +import sopt.org.umbba.api.controller.parentchild.dto.response.InviteResultResponseDto; +import sopt.org.umbba.api.controller.parentchild.dto.response.OnboardingInviteResponseDto; +import sopt.org.umbba.api.controller.parentchild.dto.response.OnboardingReceiveResponseDto; +import sopt.org.umbba.api.service.parentchild.ParentchildService; +import sopt.org.umbba.api.service.qna.QnAService; +import sopt.org.umbba.common.exception.SuccessType; +import sopt.org.umbba.common.exception.dto.ApiResponse; + + +import javax.validation.Valid; +import java.security.Principal; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class ParentchildController { + + private final ParentchildService parentchildService; + private final QnAService qnAService; + + @PostMapping("/onboard/invite") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse onboardInvite(@RequestBody @Valid final OnboardingInviteRequestDto request, Principal principal) { + + Long userId = JwtProvider.getUserFromPrincial(principal); + OnboardingInviteResponseDto response = parentchildService.onboardInvite(userId, request); + qnAService.filterFirstQuestion(userId); + + return ApiResponse.success(SuccessType.CREATE_PARENT_CHILD_SUCCESS, response); + } + + @PatchMapping("/onboard/match") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse inviteRelation(@RequestBody @Valid final InviteCodeRequestDto request, Principal principal) { + return ApiResponse.success(SuccessType.MATCH_PARENT_CHILD_SUCCESS, parentchildService.matchRelation(JwtProvider.getUserFromPrincial(principal), request)); + } + + @PatchMapping("/onboard/receive") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse onboardReceive(@RequestBody @Valid final OnboardingReceiveRequestDto request, Principal principal) throws InterruptedException { + + Long userId = JwtProvider.getUserFromPrincial(principal); + OnboardingReceiveResponseDto response = parentchildService.onboardReceive(userId, request); + qnAService.filterAllQuestion(userId); + + return ApiResponse.success(SuccessType.CREATE_PARENT_CHILD_SUCCESS, response); + } + + + +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/InviteCodeRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/InviteCodeRequestDto.java new file mode 100644 index 00000000..826589af --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/InviteCodeRequestDto.java @@ -0,0 +1,22 @@ +package sopt.org.umbba.api.controller.parentchild.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class InviteCodeRequestDto { + + @NotBlank(message = "초대코드는 필수 입력 값입니다.") + @Pattern(regexp = "[A-Z]{4}-[A-Za-z0-9]{6}", message = "초대코드 형식에 맞지 않습니다.") + @Size(max = 11) + private String inviteCode; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/OnboardingInviteRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/OnboardingInviteRequestDto.java new file mode 100644 index 00000000..82d8ff3e --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/OnboardingInviteRequestDto.java @@ -0,0 +1,39 @@ +package sopt.org.umbba.api.controller.parentchild.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sopt.org.umbba.api.controller.user.dto.request.UserInfoDto; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.time.LocalTime; +import java.util.List; + +@Slf4j +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class OnboardingInviteRequestDto { + + @NotNull + @Valid + private UserInfoDto userInfo; + + @JsonProperty("is_invitor_child") + private Boolean isInvitorChild; + + private String relationInfo; // 아들 or 딸 | 아빠 or 엄마 + + @JsonFormat(pattern = "kk:mm") + private LocalTime pushTime; + + @NotEmpty + private List onboardingAnswerList; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/OnboardingReceiveRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/OnboardingReceiveRequestDto.java new file mode 100644 index 00000000..7eb6d0fa --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/request/OnboardingReceiveRequestDto.java @@ -0,0 +1,26 @@ +package sopt.org.umbba.api.controller.parentchild.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sopt.org.umbba.api.controller.user.dto.request.UserInfoDto; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class OnboardingReceiveRequestDto { + + @NotNull + @Valid + private UserInfoDto userInfo; + + @NotEmpty + private List onboardingAnswerList; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/InviteResultResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/InviteResultResponseDto.java new file mode 100644 index 00000000..60b5cbf2 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/InviteResultResponseDto.java @@ -0,0 +1,44 @@ +package sopt.org.umbba.api.controller.parentchild.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.api.controller.user.dto.request.UserInfoDto; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class InviteResultResponseDto { + + private Long parentchildId; + + private Boolean isMatchFinish; + + private List parentchildUsers; + private String parentchildRelation; + + public static InviteResultResponseDto of(boolean isMatchFinish, Parentchild parentchild, List parentChildUsers) { + return InviteResultResponseDto.builder() + .parentchildId(parentchild.getId()) + .isMatchFinish(isMatchFinish) + .parentchildUsers(parentChildUsers.stream().map(u -> UserInfoDto.of(u)).collect(Collectors.toList())) + .parentchildRelation(parentchild.getRelation().getValue()) + .build(); + } + + public static InviteResultResponseDto of(Parentchild parentchild, List parentChildUsers) { + return InviteResultResponseDto.builder() + .parentchildId(parentchild.getId()) + .parentchildUsers(parentChildUsers.stream().map(u -> UserInfoDto.of(u)).collect(Collectors.toList())) + .parentchildRelation(parentchild.getRelation().getValue()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/OnboardingInviteResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/OnboardingInviteResponseDto.java new file mode 100644 index 00000000..bce5d63f --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/OnboardingInviteResponseDto.java @@ -0,0 +1,39 @@ +package sopt.org.umbba.api.controller.parentchild.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.api.controller.user.dto.request.UserInfoDto; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; + +import java.time.LocalTime; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class OnboardingInviteResponseDto { + + private Long parentchildId; + + private UserInfoDto userInfo; + + private String parentchildRelation; + + @JsonFormat(pattern = "kk:mm") + private LocalTime pushTime; + + private String inviteCode; + + public static OnboardingInviteResponseDto of(Parentchild parentchild, User user) { + return OnboardingInviteResponseDto.builder() + .parentchildId(parentchild.getId()) + .userInfo(UserInfoDto.of(user)) + .parentchildRelation(parentchild.getRelation().getValue()) + .pushTime(parentchild.getPushTime()) + .inviteCode(parentchild.getInviteCode()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/OnboardingReceiveResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/OnboardingReceiveResponseDto.java new file mode 100644 index 00000000..141192ab --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/parentchild/dto/response/OnboardingReceiveResponseDto.java @@ -0,0 +1,34 @@ +package sopt.org.umbba.api.controller.parentchild.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.api.controller.user.dto.request.UserInfoDto; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; + +import java.time.LocalTime; +import java.util.List; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class OnboardingReceiveResponseDto { + + private UserInfoDto userInfo; + + private InviteResultResponseDto parentchildInfo; + + @JsonFormat(pattern = "kk:mm") + private LocalTime pushTime; + + public static OnboardingReceiveResponseDto of(Parentchild parentchild, User user, List parentChildUsers) { + return OnboardingReceiveResponseDto.builder() + .userInfo(UserInfoDto.of(user)) + .parentchildInfo(InviteResultResponseDto.of(parentchild, parentChildUsers)) + .pushTime(parentchild.getPushTime()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/QnAController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/QnAController.java new file mode 100644 index 00000000..c6131f8f --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/QnAController.java @@ -0,0 +1,122 @@ +package sopt.org.umbba.api.controller.qna; + +import static sopt.org.umbba.api.config.jwt.JwtProvider.*; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import sopt.org.umbba.api.config.jwt.JwtProvider; +import sopt.org.umbba.api.controller.qna.dto.request.RerollChangeRequestDto; +import sopt.org.umbba.api.controller.qna.dto.request.TodayAnswerRequestDto; +import sopt.org.umbba.api.controller.qna.dto.response.*; +import sopt.org.umbba.api.service.qna.QnAService; +import sopt.org.umbba.common.exception.SuccessType; +import sopt.org.umbba.common.exception.dto.ApiResponse; + +import javax.validation.Valid; +import java.security.Principal; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class QnAController { + + private final QnAService qnAService; + + @GetMapping("/qna/today") + @ResponseStatus(HttpStatus.OK) + public ApiResponse getTodayQna(Principal principal) { + + return ApiResponse.success(SuccessType.GET_TODAY_QNA_SUCCESS, qnAService.getTodayQnA(JwtProvider.getUserFromPrincial(principal))); + } + + + @PostMapping("/qna/answer") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse answerTodayQuestion( + Principal principal, + @Valid @RequestBody final TodayAnswerRequestDto request) { + + qnAService.answerTodayQuestion(JwtProvider.getUserFromPrincial(principal), request); + + return ApiResponse.success(SuccessType.ANSWER_TODAY_QUESTION_SUCCESS); + } + + // 사용자가 직접 리마인드를 유도할 경우 + @GetMapping("/qna/answer/remind") + @ResponseStatus(HttpStatus.OK) + public ApiResponse remindQuestion( + Principal principal) { + + qnAService.remindQuestion(JwtProvider.getUserFromPrincial(principal)); + + return ApiResponse.success(SuccessType.REMIND_QUESTION_SUCCESS); + } + + @GetMapping("/qna/list/{sectionId}") + @ResponseStatus(HttpStatus.OK) + public ApiResponse> getQnaList( + Principal principal, + @PathVariable(name = "sectionId") Long sectionId) { + + return ApiResponse.success(SuccessType.GET_QNA_LIST_SUCCESS, + qnAService.getQnaList(JwtProvider.getUserFromPrincial(principal), sectionId)); + } + + @GetMapping("/qna/{qnaId}") + @ResponseStatus(HttpStatus.OK) + public ApiResponse getSingleQna( + Principal principal, + @PathVariable(name = "qnaId") Long qnaId) { + + return ApiResponse.success(SuccessType.GET_SINGLE_QNA_SUCCESS, + qnAService.getSingleQna(JwtProvider.getUserFromPrincial(principal), qnaId)); + } + + @PatchMapping("/qna/restart") + @ResponseStatus(HttpStatus.OK) + public ApiResponse restartQna(Principal principal) { + + qnAService.restartQna(JwtProvider.getUserFromPrincial(principal)); + return ApiResponse.success(SuccessType.RESTART_QNA_SUCCESS); + } + + @GetMapping("/home") + @ResponseStatus(HttpStatus.OK) + public ApiResponse home(Principal principal) { + + return ApiResponse.success(SuccessType.GET_MAIN_HOME_SUCCESS, qnAService.getMainInfo(JwtProvider.getUserFromPrincial(principal))); + } + + @GetMapping("/home/case") + @ResponseStatus(HttpStatus.OK) + public ApiResponse invitation(Principal principal) { + + return ApiResponse.success(SuccessType.GET_INVITE_CODE_SUCCESS, qnAService.getInvitation(JwtProvider.getUserFromPrincial(principal))); + } + + @GetMapping("/user/me") + @ResponseStatus(HttpStatus.OK) + public ApiResponse getMyUserInfo(Principal principal) { + return ApiResponse.success(SuccessType.GET_MY_USER_INFO_SUCCESS, qnAService.getUserInfo(getUserFromPrincial(principal))); + } + + @PatchMapping("/home/first") + @ResponseStatus(HttpStatus.OK) + public ApiResponse firstEntry(Principal principal) { + return ApiResponse.success(SuccessType.GET_USER_FIRST_ENTRY_SUCCESS, qnAService.updateUserFirstEntry(getUserFromPrincial(principal))); + } + + @GetMapping("/reroll/check") + @ResponseStatus(HttpStatus.OK) + public ApiResponse rerollCheck(Principal principal) { + return ApiResponse.success(SuccessType.GET_REROLL_CHECK_SUCCESS, qnAService.rerollCheck(getUserFromPrincial(principal))); + } + + @PatchMapping("/reroll/change") + @ResponseStatus(HttpStatus.OK) + public ApiResponse rerollChange(Principal principal, @RequestBody RerollChangeRequestDto request) { + qnAService.rerollChange(getUserFromPrincial(principal), request.getQuestionId()); + return ApiResponse.success(SuccessType.REROLL_CHANGE_SUCCESS); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/request/RerollChangeRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/request/RerollChangeRequestDto.java new file mode 100644 index 00000000..f2d1869f --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/request/RerollChangeRequestDto.java @@ -0,0 +1,15 @@ +package sopt.org.umbba.api.controller.qna.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class RerollChangeRequestDto { + + Long questionId; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/request/TodayAnswerRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/request/TodayAnswerRequestDto.java new file mode 100644 index 00000000..004d6d55 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/request/TodayAnswerRequestDto.java @@ -0,0 +1,16 @@ +package sopt.org.umbba.api.controller.qna.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.*; + +import javax.validation.constraints.NotBlank; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TodayAnswerRequestDto { + + @NotBlank // null, "", " "을 모두 허용하지 X + String answer; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/FirstEntryResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/FirstEntryResponseDto.java new file mode 100644 index 00000000..21d95062 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/FirstEntryResponseDto.java @@ -0,0 +1,22 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.user.User; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class FirstEntryResponseDto { + + private Boolean isFirstEntry; + + public static FirstEntryResponseDto of(boolean isFirstEntry) { + return FirstEntryResponseDto.builder() + .isFirstEntry(isFirstEntry) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/GetInvitationResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/GetInvitationResponseDto.java new file mode 100644 index 00000000..29490bf8 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/GetInvitationResponseDto.java @@ -0,0 +1,63 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class GetInvitationResponseDto { + + private int responseCase; // case를 1,2,3,4으로 구분 (Client) + + // 예외상황에 따른 필드 + private String inviteCode; + private String inviteUsername; + private String installUrl; // TODO Firebase Dynamic Link + + private Boolean relativeUserActive; + + private Boolean isUserFirstAnswer; + + + // 1. 오늘의 질문을 조회한 일반적인 경우 + public static GetInvitationResponseDto of () { + return GetInvitationResponseDto.builder() + .responseCase(1) + .relativeUserActive(true) + .isUserFirstAnswer(true) + .build(); + } + + // 2. 아직 부모자식 관계가 매칭되지 않은 경우 + public static GetInvitationResponseDto of (String inviteCode, String inviteUsername, String installUrl) { + return GetInvitationResponseDto.builder() + .responseCase(2) + .inviteCode(inviteCode) + .inviteUsername(inviteUsername) + .installUrl(installUrl) + .relativeUserActive(true) + .isUserFirstAnswer(true) + .build(); + } + + // 3. 부모자식 중 상대 측 유저가 탈퇴한 경우 + public static GetInvitationResponseDto of (boolean relativeUserActive) { + return GetInvitationResponseDto.builder() + .responseCase(3) + .relativeUserActive(relativeUserActive) + .isUserFirstAnswer(true) + .build(); + } + + // 4. 아직 첫 질문에 답변하지 않은 경우 + public static GetInvitationResponseDto ofFirst (boolean isUserFirstAnswer) { + return GetInvitationResponseDto.builder() + .responseCase(4) + .relativeUserActive(true) + .isUserFirstAnswer(isUserFirstAnswer) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/GetMainViewResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/GetMainViewResponseDto.java new file mode 100644 index 00000000..bf00508a --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/GetMainViewResponseDto.java @@ -0,0 +1,26 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.qna.QnA; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class GetMainViewResponseDto { + + private String section; + private String topic; + private Integer index; + + public static GetMainViewResponseDto of(QnA qnA, int index) { + return GetMainViewResponseDto.builder() + .section(qnA.getQuestion().getSection().getValue()) + .topic(qnA.getQuestion().getTopic()) + .index(index) + .build(); + } + +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/MyUserInfoResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/MyUserInfoResponseDto.java new file mode 100644 index 00000000..a1b7fc6d --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/MyUserInfoResponseDto.java @@ -0,0 +1,65 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import static sopt.org.umbba.domain.domain.parentchild.ParentchildRelation.*; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.qna.QnA; +import sopt.org.umbba.domain.domain.qna.QuestionSection; +import sopt.org.umbba.domain.domain.user.User; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class MyUserInfoResponseDto { + + private String myUsername; + private String myUserType; + private String opponentUsername; + private String opponentUserType; + + private String parentchildRelation; + private Boolean isMeChild; + + private String section; + private Long matchedDate; + private Integer qnaCnt; + + private String inviteCode; + private String installUrl; + + public static MyUserInfoResponseDto of(User myUser, User opponentUser, Parentchild parentchild, QnA qnA, long date, int qnaCnt) { + + return MyUserInfoResponseDto.builder() + .myUsername(myUser.getUsername()) + .myUserType(getUserType(parentchild.getRelation(), myUser.isMeChild())) + .opponentUsername(opponentUser.getUsername()) + .opponentUserType(getUserType(parentchild.getRelation(), opponentUser.isMeChild())) + .parentchildRelation(parentchild.getRelation().getValue()) + .isMeChild(myUser.isMeChild()) + .section(qnA.getQuestion().getSection().getValue()) + .matchedDate(date) // 일수와 문답 수는 다를 수 있음 + .qnaCnt(qnaCnt).build(); + } + + // 아직 매칭된 유저가 없는 경우 + public static MyUserInfoResponseDto of(User myUser, Parentchild parentchild) { + + return MyUserInfoResponseDto.builder() + .myUsername(myUser.getUsername()) + .myUserType(getUserType(parentchild.getRelation(), myUser.isMeChild())) + .opponentUserType(getUserType(parentchild.getRelation(), !myUser.isMeChild())) + .parentchildRelation(parentchild.getRelation().getValue()) + .isMeChild(myUser.isMeChild()) + .section(QuestionSection.YOUNG.getValue()) + .matchedDate(0L) + .qnaCnt(0) + .inviteCode(parentchild.getInviteCode()) + .installUrl("http://umbba.site/") + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/QnAListResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/QnAListResponseDto.java new file mode 100644 index 00000000..50931530 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/QnAListResponseDto.java @@ -0,0 +1,16 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class QnAListResponseDto { + + private Long qnaId; + private Integer index; + private String topic; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/RerollCheckResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/RerollCheckResponseDto.java new file mode 100644 index 00000000..c7ddd46e --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/RerollCheckResponseDto.java @@ -0,0 +1,31 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.qna.Question; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class RerollCheckResponseDto { + + private Long questionId; + private String newQuestion; + + public static RerollCheckResponseDto of(boolean isMeChild, Question question) { + + String newQuestion; + if (isMeChild) { + newQuestion = question.getChildQuestion(); + } else { + newQuestion = question.getParentQuestion(); + } + + return RerollCheckResponseDto.builder() + .questionId(question.getId()) + .newQuestion(newQuestion) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/SingleQnAResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/SingleQnAResponseDto.java new file mode 100644 index 00000000..55372ff7 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/SingleQnAResponseDto.java @@ -0,0 +1,62 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.qna.QnA; +import sopt.org.umbba.domain.domain.qna.Question; +import sopt.org.umbba.domain.domain.user.User; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class SingleQnAResponseDto { + + private Long qnaId; + private Integer index; + + private String section; + private String topic; + + private String opponentQuestion; + private String myQuestion; + + private String opponentAnswer; + private String myAnswer; + + private String opponentUsername; + private String myUsername; + + public static SingleQnAResponseDto of(User myUser, User opponentUser, int index, QnA todayQnA, Question todayQuestion) { + String opponentQuestion; + String myQuestion; + String opponentAnswer; + String myAnswer; + + if (myUser.isMeChild()) { + opponentQuestion = todayQuestion.getParentQuestion(); + myQuestion = todayQuestion.getChildQuestion(); + opponentAnswer = todayQnA.getParentAnswer(); + myAnswer = todayQnA.getChildAnswer(); + } else { + opponentQuestion = todayQuestion.getChildQuestion(); + myQuestion = todayQuestion.getParentQuestion(); + opponentAnswer = todayQnA.getChildAnswer(); + myAnswer = todayQnA.getParentAnswer(); + } + + return SingleQnAResponseDto.builder() + .qnaId(todayQnA.getId()) + .index(index) + .section(todayQuestion.getSection().getValue()) + .topic(todayQuestion.getTopic()) + .opponentQuestion(opponentQuestion) + .myQuestion(myQuestion) + .opponentAnswer(opponentAnswer) + .myAnswer(myAnswer) + .opponentUsername(opponentUser.getUsername()) + .myUsername(myUser.getUsername()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/TodayQnAResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/TodayQnAResponseDto.java new file mode 100644 index 00000000..5790c66f --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/dto/response/TodayQnAResponseDto.java @@ -0,0 +1,112 @@ +package sopt.org.umbba.api.controller.qna.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.qna.QnA; +import sopt.org.umbba.domain.domain.qna.Question; +import sopt.org.umbba.domain.domain.user.User; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TodayQnAResponseDto { + + private Long qnaId; + private Integer index; + private String section; + private String topic; + private String opponentQuestion; + private String myQuestion; + + private String opponentAnswer; + private String myAnswer; + + private Boolean isOpponentAnswer; + private Boolean isMyAnswer; + + private String opponentUsername; + private String myUsername; + + private Boolean isRerollTime; + + + public static TodayQnAResponseDto of(User myUser, User opponentUser, int count, QnA todayQnA, Question todayQuestion) { + String opponentQuestion; + String myQuestion; + String opponentAnswer; + String myAnswer; + boolean isOpponentAnswer; + boolean isMyAnswer; + + boolean isRerollAvailable = true; + + // 하루에 한번만 질문 새로고침 가능 + LocalDateTime lastRerollChange = myUser.getLastRerollChange(); + LocalDateTime now = LocalDateTime.now(); + if (lastRerollChange != null) { + Duration duration = Duration.between(lastRerollChange, now); + long hoursPassed = duration.toHours(); + + if (hoursPassed < 24) { + isRerollAvailable = false; + } + } + + if (myUser.isMeChild()) { + opponentQuestion = todayQuestion.getParentQuestion(); + myQuestion = todayQuestion.getChildQuestion(); + opponentAnswer = todayQnA.getParentAnswer(); + myAnswer = todayQnA.getChildAnswer(); + isOpponentAnswer = todayQnA.isParentAnswer(); + isMyAnswer = todayQnA.isChildAnswer(); + } else { + opponentQuestion = todayQuestion.getChildQuestion(); + myQuestion = todayQuestion.getParentQuestion(); + opponentAnswer = todayQnA.getChildAnswer(); + myAnswer = todayQnA.getParentAnswer(); + isOpponentAnswer = todayQnA.isChildAnswer(); + isMyAnswer = todayQnA.isParentAnswer(); + } + + if (opponentUser != null) { + return TodayQnAResponseDto.builder() + .qnaId(todayQnA.getId()) + .index(count) + .section(todayQuestion.getSection().getValue()) + .topic(todayQuestion.getTopic()) + .opponentQuestion(opponentQuestion) + .myQuestion(myQuestion) + .opponentAnswer(opponentAnswer) + .myAnswer(myAnswer) + .isOpponentAnswer(isOpponentAnswer) + .isMyAnswer(isMyAnswer) + .opponentUsername(opponentUser.getUsername()) + .myUsername(myUser.getUsername()) + .isRerollTime(isRerollAvailable) + .build(); + } else { + return TodayQnAResponseDto.builder() + .qnaId(todayQnA.getId()) + .index(count) + .section(todayQuestion.getSection().getValue()) + .topic(todayQuestion.getTopic()) + .opponentQuestion(opponentQuestion) + .myQuestion(myQuestion) + .opponentAnswer(opponentAnswer) + .myAnswer(myAnswer) + .isOpponentAnswer(isOpponentAnswer) + .isMyAnswer(isMyAnswer) + .opponentUsername("상대방") + .myUsername(myUser.getUsername()) + .isRerollTime(isRerollAvailable) + .build(); + } + } +} + + diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/AuthController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/AuthController.java new file mode 100644 index 00000000..2c9407fa --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/AuthController.java @@ -0,0 +1,68 @@ +package sopt.org.umbba.api.controller.user; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import sopt.org.umbba.api.config.jwt.JwtProvider; +import sopt.org.umbba.api.config.jwt.TokenDto; +import sopt.org.umbba.api.controller.user.dto.request.RefreshRequestDto; +import sopt.org.umbba.api.controller.user.dto.request.SocialLoginRequestDto; +import sopt.org.umbba.api.controller.user.dto.response.UserLoginResponseDto; +import sopt.org.umbba.api.service.user.AuthService; +import sopt.org.umbba.api.service.user.social.kakao.KakaoLoginService; +import sopt.org.umbba.common.exception.SuccessType; +import sopt.org.umbba.common.exception.dto.ApiResponse; + +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.spec.InvalidKeySpecException; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final KakaoLoginService kakaoLoginService; + + @PostMapping("/login") + @ResponseStatus(HttpStatus.OK) + public ApiResponse login( + @RequestHeader("Authorization") String socialAccessToken, + @RequestBody final SocialLoginRequestDto request) throws NoSuchAlgorithmException, InvalidKeySpecException { + + return ApiResponse.success(SuccessType.LOGIN_SUCCESS, authService.login(socialAccessToken, request)); + } + + @PostMapping("/reissue") + @ResponseStatus(HttpStatus.OK) + public ApiResponse reissue( + @RequestHeader("Authorization") String refreshToken, + @RequestBody final RefreshRequestDto request) throws Exception { + + return ApiResponse.success(SuccessType.REISSUE_SUCCESS, authService.reissueToken(request, refreshToken)); + } + + @PatchMapping("/log-out") // Spring Security 자체 로그아웃과 충돌하기 때문에 이렇게 써줌 + @ResponseStatus(HttpStatus.OK) + public ApiResponse logout(Principal principal) { + + authService.logout(JwtProvider.getUserFromPrincial(principal)); + return ApiResponse.success(SuccessType.LOGOUT_SUCCESS); + } + + @PatchMapping("/sign-out") + @ResponseStatus(HttpStatus.OK) + public ApiResponse signout(Principal principal) { + + authService.signout(JwtProvider.getUserFromPrincial(principal)); + return ApiResponse.success(SuccessType.SIGNOUT_SUCCESS); + } + + @PostMapping("/kakao") + @ResponseStatus(HttpStatus.OK) + public ApiResponse kakaoAccessToken( + @RequestHeader("Authorization") String code) { + + return ApiResponse.success(SuccessType.KAKAO_ACCESS_TOKEN_SUCCESS, kakaoLoginService.getKakaoAccessToken(code)); + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/RefreshRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/RefreshRequestDto.java new file mode 100644 index 00000000..525728fa --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/RefreshRequestDto.java @@ -0,0 +1,15 @@ +package sopt.org.umbba.api.controller.user.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class RefreshRequestDto { + + private Long userId; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/SocialLoginRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/SocialLoginRequestDto.java new file mode 100644 index 00000000..c89ced53 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/SocialLoginRequestDto.java @@ -0,0 +1,17 @@ +package sopt.org.umbba.api.controller.user.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class SocialLoginRequestDto { + + private String socialPlatform; + + private String fcmToken; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/UserInfoDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/UserInfoDto.java new file mode 100644 index 00000000..e3dddd6b --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/request/UserInfoDto.java @@ -0,0 +1,40 @@ +package sopt.org.umbba.api.controller.user.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.*; +import sopt.org.umbba.domain.domain.user.User; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class UserInfoDto { + + private Long userId; + + @NotBlank(message = "이름은 필수 입력 값입니다.") + private String name; + + @NotBlank(message = "성별은 필수 입력 값입니다.") + private String gender; + + @NotNull(message = "출생연도는 필수 입력 값입니다.") + private Integer bornYear; + + private Boolean isMeChild; + + public static UserInfoDto of(User user) { + return UserInfoDto.builder() + .userId(user.getId()) + .name(user.getUsername()) + .gender(user.getGender()) + .bornYear(user.getBornYear()) + .isMeChild(user.isMeChild()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/response/UserLoginResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/response/UserLoginResponseDto.java new file mode 100644 index 00000000..015b8442 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/user/dto/response/UserLoginResponseDto.java @@ -0,0 +1,57 @@ +package sopt.org.umbba.api.controller.user.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.api.config.jwt.TokenDto; +import sopt.org.umbba.domain.domain.user.SocialPlatform; +import sopt.org.umbba.domain.domain.user.User; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class UserLoginResponseDto { + private Long userId; + + private Boolean isMatchFinish; + + private String username; + + private String gender; + + private Integer bornYear; + + private TokenDto tokenDto; + + private String fcmToken; + + private SocialPlatform socialPlatform; + + private String socialNickname; + + private String socialProfileImage; + + private String socialAccessToken; + +// private String socialRefreshToken; + + public static UserLoginResponseDto of(User loginUser, String accessToken) { + TokenDto tokenDto = TokenDto.of(accessToken, loginUser.getRefreshToken()); + + return UserLoginResponseDto.builder() + .userId(loginUser.getId()) + .isMatchFinish(loginUser.isMatchFinish()) + .username(loginUser.getUsername()) + .gender(loginUser.getGender()) + .bornYear(loginUser.getBornYear()) + .tokenDto(tokenDto) + .fcmToken(loginUser.getFcmToken()) + .socialPlatform(loginUser.getSocialPlatform()) + .socialNickname(loginUser.getSocialNickname()) + .socialProfileImage(loginUser.getSocialProfileImage()) + .socialAccessToken(loginUser.getSocialAccessToken()) + /*, loginUser.getSocialRefreshToken()*/ + .build(); + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/album/AlbumService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/album/AlbumService.java new file mode 100644 index 00000000..1a0af765 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/album/AlbumService.java @@ -0,0 +1,121 @@ +package sopt.org.umbba.api.service.album; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.api.controller.album.dto.request.CreateAlbumRequestDto; +import sopt.org.umbba.api.controller.album.dto.response.AlbumResponseDto; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.album.Album; +import sopt.org.umbba.domain.domain.album.repository.AlbumRepository; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AlbumService { + + private final AlbumRepository albumRepository; + private final UserRepository userRepository; + + public static final String ALBUM_EXAMPLE = "example"; + + @Transactional + public Long createAlbum(final CreateAlbumRequestDto request, final String imgUrl, final Long userId) { + + User user = getUserById(userId); + Parentchild parentchild = getParentchildByUser(user); + + if (parentchild.isOverMaxAlbumLimit()) { + throw new CustomException(ErrorType.MAX_LIMIT_ALBUM_UPLOAD); + } + + // 앨범을 처음 등록하는 경우 + if (parentchild.getAlbumList().isEmpty() && !parentchild.isFirstAlbumUpload()) { + parentchild.updateFirstAlbumUpload(); + } + + Album album = Album.builder() + .title(request.getTitle()) + .content(request.getContent()) + .imgUrl(imgUrl) + .writer(user.getUsername()) + .parentchild(parentchild) + .build(); + albumRepository.save(album); + album.setParentchild(parentchild); + parentchild.addAlbum(album); + + return album.getId(); + } + + @Transactional + public String deleteAlbum(final Long albumId, final Long userId) { + + User user = getUserById(userId); + Parentchild parentchild = getParentchildByUser(user); + + // Sample Album을 삭제할 경우 + if (albumId.equals(0L)) { + parentchild.updateDeleteSampleAlbum(); + return ALBUM_EXAMPLE; + } + + Album album = getAlbumById(albumId); + + album.deleteParentchild(); + parentchild.deleteAlbum(album); + albumRepository.delete(album); + + return album.getImgUrl(); + } + + public List getAlbumList(final Long userId) { + User user = getUserById(userId); + Parentchild parentchild = getParentchildByUser(user); + List albumList = albumRepository.findAllByParentchildOrderByCreatedAtDesc( + parentchild); + + // Album을 아직 한번도 등록하지 않은 경우 + if (albumList.isEmpty() && !parentchild.isFirstAlbumUpload() && !parentchild.isDeleteSampleAlbum()) { + return List.of(AlbumResponseDto.of(createAlbumExample())); + } + + return albumList.stream() + .map(AlbumResponseDto::of) + .collect(Collectors.toList()); + } + + private Album createAlbumExample() { + return new Album(0L, "사진의 제목을 입력할 수 있어요", "사진에 대해 소개해요", + "https://i1.sndcdn.com/artworks-l2lCmUXC61XR2HM5-gwB8Vg-t500x500.jpg", "직성자"); // TODO 기획 측에서 전달받은 이미지 url로 변경 + } + + private User getUserById(Long userId) { // TODO userId -> Parentchild 한번에 가져오기 + return userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorType.INVALID_USER) + ); + } + + private Album getAlbumById(Long albumId) { + return albumRepository.findById(albumId).orElseThrow( + () -> new CustomException(ErrorType.NOT_FOUND_ALBUM) + ); + } + + private Parentchild getParentchildByUser(User user) { + Parentchild parentchild = user.getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + return parentchild; + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/closer/CloserService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/closer/CloserService.java new file mode 100644 index 00000000..7e973782 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/closer/CloserService.java @@ -0,0 +1,163 @@ +package sopt.org.umbba.api.service.closer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sopt.org.umbba.api.controller.closer.dto.request.TodayCloserAnswerRequestDto; +import sopt.org.umbba.api.controller.closer.dto.response.TodayCloserQnAResponseDto; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.closer.CloserQnA; +import sopt.org.umbba.domain.domain.closer.CloserQuestion; +import sopt.org.umbba.domain.domain.closer.repository.CloserQnARepository; +import sopt.org.umbba.domain.domain.closer.repository.CloserQuestionRepository; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CloserService { + + private final UserRepository userRepository; + private final CloserQuestionRepository closerQuestionRepository; + private final CloserQnARepository closerQnARepository; + + @Transactional + public TodayCloserQnAResponseDto getTodayCloserQnA(Long userId) { + User user = getUserById(userId); + Parentchild parentchild = user.getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + if (parentchild.getCloserQnaList().isEmpty()) { + addFirstCloserQnA(parentchild); + } + + if (user.isMeChild()) { + int closerCount = parentchild.getCloserChildCount(); + CloserQnA todayQnA = parentchild.getCloserQnaList().get(closerCount); + + if (!todayQnA.isChildAnswer()) { // Case 1 (내가 답변하지 않은 경우) + return TodayCloserQnAResponseDto.of(todayQnA, 1, true); + } else if (!todayQnA.isParentAnswer()) { // Case 2 (상대가 답변하지 않은 경우) + return TodayCloserQnAResponseDto.of(todayQnA, 2, true); + } else { // Case 3,4 (둘다 답변한 경우) + return TodayCloserQnAResponseDto.of(todayQnA, 3, true); + } + + } else { + int closerCount = parentchild.getCloserParentCount(); + CloserQnA todayQnA = parentchild.getCloserQnaList().get(closerCount); + + if (!todayQnA.isParentAnswer()) { // Case 1 (내가 답변하지 않은 경우) + return TodayCloserQnAResponseDto.of(todayQnA, 1, false); + } else if (!todayQnA.isChildAnswer()) { // Case 2 (상대가 답변하지 않은 경우) + return TodayCloserQnAResponseDto.of(todayQnA, 2, false); + } else { // Case 3,4 (둘다 답변한 경우) + return TodayCloserQnAResponseDto.of(todayQnA, 3, false); + } + } + } + + @Transactional + public void addFirstCloserQnA(Parentchild parentchild) { + CloserQuestion firstCloserQuestion = closerQuestionRepository.findRandomExceptIds(new ArrayList<>()) + .orElseThrow(() -> new CustomException(ErrorType.NO_MORE_CLOSER_QUESTION)); + + CloserQnA newCloserQnA = CloserQnA.builder() + .closerQuestion(firstCloserQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + closerQnARepository.save(newCloserQnA); + parentchild.addCloserQna(newCloserQnA); + } + + @Transactional + public void answerTodayCloserQnA(Long userId, TodayCloserAnswerRequestDto request) { + User user = getUserById(userId); + Parentchild parentchild = user.getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + if (user.isMeChild()) { + int closerCount = parentchild.getCloserChildCount(); + CloserQnA todayQnA = parentchild.getCloserQnaList().get(closerCount); + + todayQnA.saveChildAnswer(request.getAnswer()); + } else { + int closerCount = parentchild.getCloserParentCount(); + CloserQnA todayQnA = parentchild.getCloserQnaList().get(closerCount); + + todayQnA.saveParentAnswer(request.getAnswer()); + } + } + + @Transactional + public void passToNextCloserQnA(Long userId) { + User user = getUserById(userId); + Parentchild parentchild = user.getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + if (user.isMeChild()) { + if (parentchild.getCloserChildCount() < parentchild.getCloserParentCount()) { + parentchild.addCloserChildCount(); + } else if (parentchild.getCloserChildCount() == parentchild.getCloserParentCount()) { + parentchild.addCloserChildCount(); + CloserQuestion newCloserQuestion = closerQuestionRepository.findRandomExceptIds(getCloserQuestionIds(parentchild)) + .orElseThrow(() -> new CustomException(ErrorType.NO_MORE_CLOSER_QUESTION)); + CloserQnA newCloserQnA = CloserQnA.builder() + .closerQuestion(newCloserQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + closerQnARepository.save(newCloserQnA); + parentchild.addCloserQna(newCloserQnA); + } else { + throw new CustomException(ErrorType.INVALID_COUNT_STATUS); + } + } else { + if (parentchild.getCloserParentCount() < parentchild.getCloserChildCount()) { + parentchild.addCloserParentCount(); + } else if (parentchild.getCloserParentCount() == parentchild.getCloserChildCount()) { + parentchild.addCloserParentCount(); + CloserQuestion newCloserQuestion = closerQuestionRepository.findRandomExceptIds(getCloserQuestionIds(parentchild)) + .orElseThrow(() -> new CustomException(ErrorType.NO_MORE_CLOSER_QUESTION)); + CloserQnA newCloserQnA = CloserQnA.builder() + .closerQuestion(newCloserQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + closerQnARepository.save(newCloserQnA); + parentchild.addCloserQna(newCloserQnA); + } else { + throw new CustomException(ErrorType.INVALID_COUNT_STATUS); + } + } + } + + private static List getCloserQuestionIds(Parentchild parentchild) { + return parentchild.getCloserQnaList().stream() + .map(closerQnA -> closerQnA.getCloserQuestion().getId()) + .collect(Collectors.toList()); + } + + private User getUserById(Long userId) { + + return userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorType.INVALID_USER) + ); + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/notification/NotificationService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/notification/NotificationService.java new file mode 100644 index 00000000..e427f457 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/notification/NotificationService.java @@ -0,0 +1,74 @@ +package sopt.org.umbba.api.service.notification; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sopt.org.umbba.api.config.sqs.producer.SqsProducer; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto; +import sopt.org.umbba.common.sqs.dto.ScheduleDto; +import sopt.org.umbba.common.sqs.dto.SlackDto; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +/** + * SQS 대기열로 알림 메시지를 추가 + */ +@Slf4j +@RequiredArgsConstructor +@Service +@Transactional +public class NotificationService { + + private final SqsProducer sqsProducer; + private final UserRepository userRepository; + + public void pushOpponentReply(String question, Long userId) { + + // 상대 측 유저의 FCM 토큰 찾기 + User user = userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorType.INVALID_USER) + ); + + log.info("상대방 답변 완료!"); + sqsProducer.produce(FCMPushRequestDto.sendOpponentReply(user.getFcmToken(), question)); + + /* try { + log.info("상대방 답변 완료!"); + sqsProducer.produce(FCMPushRequestDto.sendOpponentReply(user.getFcmToken(), question)); + } catch (IOException e) { + log.error("푸시메시지 전송 실패 - IOException: {}", e.getMessage()); + throw new CustomException(ErrorType.FAIL_TO_SEND_PUSH_ALARM); + } catch (FirebaseMessagingException e) { + log.error("푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage()); + throw new CustomException(ErrorType.FAIL_TO_SEND_PUSH_ALARM); + }*/ + } + + public void activateTodayQna() { + sqsProducer.produce(ScheduleDto.of()); + } + + // 콕찌르기 기능 추가 시 사용 + public void pushOpponentRemind(Long userId, String topic) { + + // 상대 측 유저의 FCM 토큰 찾기 + User user = userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorType.INVALID_USER) + ); + + log.info("리마인드할 상대방 조회 완료!"); + sqsProducer.produce(FCMPushRequestDto.sendOpponentRemind(user.getFcmToken(), topic, 24)); + } + + public void sendExceptionToSlack(Exception e, HttpServletRequest request) { + sqsProducer.produce(SlackDto.of(e, request.getMethod(), request.getRequestURI())); + } + + +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/parentchild/ParentchildService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/parentchild/ParentchildService.java new file mode 100644 index 00000000..61f237e2 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/parentchild/ParentchildService.java @@ -0,0 +1,179 @@ +package sopt.org.umbba.api.service.parentchild; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sopt.org.umbba.api.controller.parentchild.dto.request.InviteCodeRequestDto; +import sopt.org.umbba.api.controller.parentchild.dto.request.OnboardingInviteRequestDto; +import sopt.org.umbba.api.controller.parentchild.dto.request.OnboardingReceiveRequestDto; +import sopt.org.umbba.api.controller.parentchild.dto.response.InviteResultResponseDto; +import sopt.org.umbba.api.controller.parentchild.dto.response.OnboardingInviteResponseDto; +import sopt.org.umbba.api.controller.parentchild.dto.response.OnboardingReceiveResponseDto; +import sopt.org.umbba.api.service.notification.NotificationService; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.parentchild.ParentchildRelation; +import sopt.org.umbba.domain.domain.parentchild.dao.ParentchildDao; +import sopt.org.umbba.domain.domain.parentchild.repository.ParentchildRepository; +import sopt.org.umbba.domain.domain.qna.OnboardingAnswer; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; + +import javax.validation.Valid; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ParentchildService { + + private final ParentchildRepository parentchildRepository; + private final UserRepository userRepository; + private final ParentchildDao parentchildDao; + private final NotificationService notificationService; + + // [발신] 초대하는 측의 온보딩 정보 입력 + @Transactional + public OnboardingInviteResponseDto onboardInvite(Long userId, OnboardingInviteRequestDto request) { + + User user = getUserById(userId); + user.updateOnboardingInfo( + request.getUserInfo().getName(), + request.getUserInfo().getGender(), + request.getUserInfo().getBornYear() + ); + log.info("isInvitorChild 요청값: {}", request.getIsInvitorChild()); + user.updateIsMeChild(request.getIsInvitorChild()); + log.info("업데이트 된 isMeChild 필드: {}", user.isMeChild()); + + Parentchild parentchild = Parentchild.builder() + .inviteCode(generateInviteCode()) + .isInvitorChild(request.getIsInvitorChild()) + .relation(ParentchildRelation.relation(request.getUserInfo().getGender(), request.getRelationInfo(), request.getIsInvitorChild())) + .pushTime(request.getPushTime()) + .count(1) + .build(); + parentchildRepository.save(parentchild); + user.updateParentchild(parentchild); + user.updateIsMatchFinish(true); + log.info("userInfo: {}", request.getUserInfo().getBornYear()); + + // String을 Enum으로 변경 + List onboardingAnswerList = request.getOnboardingAnswerList().stream() + .map(OnboardingAnswer::of) + .collect(Collectors.toList()); + + if (onboardingAnswerList.size() != 5) { + throw new CustomException(ErrorType.INVALID_ONBOARDING_ANSWER_SIZE); + } + + if (getUserById(userId).isMeChild()) { + parentchild.changeChildOnboardingAnswerList(onboardingAnswerList); + } else { + parentchild.changeParentOnboardingAnswerList(onboardingAnswerList); + } + + return OnboardingInviteResponseDto.of(parentchild, user); + + } + + + // [수신] 초대받는 측의 온보딩 정보 입력 + @Transactional + public OnboardingReceiveResponseDto onboardReceive(Long userId, OnboardingReceiveRequestDto request) throws InterruptedException { + + + if (getUserById(userId).getParentChild() == null) { + throw new CustomException(ErrorType.RECEIVE_AFTER_MATCH); + } + + User user = getUserById(userId); + user.updateOnboardingInfo( + request.getUserInfo().getName(), + request.getUserInfo().getGender(), + request.getUserInfo().getBornYear() + ); + + Parentchild parentchild = user.getParentChild(); +// parentchild.updateInfo(); TODO 온보딩 송수신 측의 관계 정보가 불일치한 경우에 대한 처리 + List parentChildUsers = getParentChildUsers(parentchild); + + // String을 Enum으로 변경 + List onboardingAnswerList = request.getOnboardingAnswerList().stream() + .map(OnboardingAnswer::of) + .collect(Collectors.toList()); + + if (onboardingAnswerList.size() != 5) { + throw new CustomException(ErrorType.INVALID_ONBOARDING_ANSWER_SIZE); + } + + if (getUserById(userId).isMeChild()) { + parentchild.changeChildOnboardingAnswerList(onboardingAnswerList); + } else { + parentchild.changeParentOnboardingAnswerList(onboardingAnswerList); + } + + /*if (!ParentchildRelation.validate(parentChildUsers, parentchild.getRelation())) { + throw new CustomException(ErrorType.INVALID_PARENT_CHILD_RELATION); + }*/ + + notificationService.activateTodayQna(); +// ScheduleConfig.resetScheduler(); +// fcmScheduler.pushTodayQna(); + + return OnboardingReceiveResponseDto.of(parentchild, user, parentChildUsers); + + } + + + // 초대코드 생성 (형식예시: WUHZ-iGbPX9X) + private String generateInviteCode() { + return RandomStringUtils.randomAlphabetic(4).toUpperCase() + + "-" + RandomStringUtils.randomAlphanumeric(6); + } + + // 초대코드 확인 후 부모자식 관계 성립 + @Transactional + public InviteResultResponseDto matchRelation(Long userId, @Valid InviteCodeRequestDto request) { + + log.info("ParentchlidService 실행 - 요청 초대코드: {}", request.getInviteCode()); + Parentchild newMatchRelation = parentchildRepository.findByInviteCode(request.getInviteCode()).orElseThrow( + () -> new CustomException(ErrorType.INVALID_INVITE_CODE)); + User user = getUserById(userId); + user.updateIsMeChild(!newMatchRelation.isInvitorChild()); + + if (user.getParentChild() != null) { + throw new CustomException(ErrorType.ALREADY_EXISTS_PARENT_CHILD_USER); + } + + // TODO ParentChild에 연관된 User 수에 따른 예외 메시지 출력 + user.updateParentchild(newMatchRelation); + user.updateIsMatchFinish(true); + log.info("로그인한 유저가 성립된 Parentchild Id: {}", user.getParentChild().getId()); + + List parentChildUsers = getParentChildUsers(newMatchRelation); + if (!user.validateParentchild(parentChildUsers)) { + throw new CustomException(ErrorType.INVALID_PARENT_CHILD_RELATION); + } + + return InviteResultResponseDto.of(user.isMatchFinish(), newMatchRelation, parentChildUsers); + } + + public List getParentChildUsers(Parentchild newMatchRelation) { + return userRepository.findUserByParentChild(newMatchRelation); + } + + + private User getUserById(Long userId) { + + return userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorType.INVALID_USER) + ); + } + +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/qna/QnAService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/qna/QnAService.java new file mode 100644 index 00000000..9f53828e --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/qna/QnAService.java @@ -0,0 +1,649 @@ +package sopt.org.umbba.api.service.qna; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import sopt.org.umbba.api.controller.qna.dto.response.MyUserInfoResponseDto; +import sopt.org.umbba.api.controller.qna.dto.request.TodayAnswerRequestDto; +import sopt.org.umbba.api.controller.qna.dto.response.*; +import sopt.org.umbba.api.service.notification.NotificationService; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.closer.CloserQnA; +import sopt.org.umbba.domain.domain.closer.CloserQuestion; +import sopt.org.umbba.domain.domain.closer.repository.CloserQnARepository; +import sopt.org.umbba.domain.domain.closer.repository.CloserQuestionRepository; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.parentchild.dao.ParentchildDao; +import sopt.org.umbba.domain.domain.qna.*; +import sopt.org.umbba.domain.domain.qna.repository.QnARepository; +import sopt.org.umbba.domain.domain.qna.repository.QuestionRepository; +import sopt.org.umbba.domain.domain.user.SocialPlatform; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; + +import javax.validation.constraints.NotNull; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.*; +import java.util.stream.Collectors; + +import static sopt.org.umbba.common.exception.ErrorType.NEED_MORE_QUESTION; +import static sopt.org.umbba.domain.domain.qna.OnboardingAnswer.NO; +import static sopt.org.umbba.domain.domain.qna.OnboardingAnswer.YES; +import static sopt.org.umbba.domain.domain.qna.QuestionSection.*; +import static sopt.org.umbba.domain.domain.qna.QuestionType.*; + + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QnAService { + + private final QnARepository qnARepository; + private final QuestionRepository questionRepository; + private final UserRepository userRepository; + private final ParentchildDao parentchildDao; + private final NotificationService notificationService; + + private final CloserQuestionRepository closerQuestionRepository; + private final CloserQnARepository closerQnARepository; + + public TodayQnAResponseDto getTodayQnA(Long userId) { + + User myUser = getUserById(userId); + Parentchild parentchild = getParentchildByUser(myUser); + QnA todayQnA = getTodayQnAByParentchild(parentchild); + Question todayQuestion = todayQnA.getQuestion(); + + User opponentUser; + List opponentUserList = userRepository.findUserByParentChild(parentchild) + .stream() + .filter(user -> !user.getId().equals(userId)) + .collect(Collectors.toList()); + if (opponentUserList.isEmpty()) { + return TodayQnAResponseDto.of(myUser, null, parentchild.getCount(), todayQnA, todayQuestion); + } else { + opponentUser = opponentUserList.get(0); + return TodayQnAResponseDto.of(myUser, opponentUser, parentchild.getCount(), todayQnA, todayQuestion); + } + } + + public GetInvitationResponseDto getInvitation(Long userId) { + + Optional matchUser = parentchildDao.findMatchUserByUserId(userId); + log.info("matchUser: {} -> parentchildDao.findMatchUserByUserId()의 결과", matchUser); + + // 유저의 상태에 따른 분기처리 + if (!checkFirstAnswerCompleted(userId)) { + return firstTutorialQnA(); + } + else if (matchUser.isEmpty()) { + return invitation(userId); + } + else if (matchUser.get().getUsername() == null) { + return invitation(userId); + } + else if (matchUser.get().getSocialPlatform().equals(SocialPlatform.WITHDRAW)) { + return withdrawUser(); + } + + return GetInvitationResponseDto.of(); + } + private boolean checkFirstAnswerCompleted(Long userId) { + User user = getUserById(userId); + QnA firstQnA = user.getParentChild().getQnaList().get(0); + + if (firstQnA.isChildAnswer() || firstQnA.isParentAnswer()) { // 본인이 초대 하는측이라 질문을 하나라도 완료 했을 경우 + 초대 받는측이라 상대가 질문을 완료 했을 경우 + return true; + } + return false; + } + + @Transactional + public void answerTodayQuestion(Long userId, TodayAnswerRequestDto request) { + User myUser = getUserById(userId); + Parentchild parentchild = getParentchildByUser(myUser); + QnA todayQnA = getTodayQnAByParentchild(parentchild); + + List opponentUserList = userRepository.findUserByParentChild(parentchild) + .stream() + .filter(user -> !user.getId().equals(userId)) + .collect(Collectors.toList()); + + if (opponentUserList.isEmpty()) { + if (myUser.isMeChild()) { + todayQnA.saveChildAnswer(request.getAnswer()); + } else { + todayQnA.saveParentAnswer(request.getAnswer()); + } + } else { + User opponentUser = opponentUserList.get(0); + + if (myUser.isMeChild()) { + todayQnA.saveChildAnswer(request.getAnswer()); + notificationService.pushOpponentReply(todayQnA.getQuestion().getChildQuestion(), opponentUser.getId()); +// fcmService.pushOpponentReply(todayQnA.getQuestion().getChildQuestion(), opponentUser.getId()); + } else { + todayQnA.saveParentAnswer(request.getAnswer()); + notificationService.pushOpponentReply(todayQnA.getQuestion().getParentQuestion(), opponentUser.getId()); +// fcmService.pushOpponentReply(todayQnA.getQuestion().getParentQuestion(), opponentUser.getId()); + } + } + } + + // 콕찌르기와 같이 특정 이벤트로 리마인드 알림을 발신할 경우 + @Transactional + public void remindQuestion(Long userId) { + User myUser = getUserById(userId); + Parentchild parentchild = getParentchildByUser(myUser); + User opponentUser = getOpponentByParentchild(parentchild, userId); + QnA todayQnA = getTodayQnAByParentchild(parentchild); + + + notificationService.pushOpponentRemind(opponentUser.getId(), todayQnA.getQuestion().getTopic()); + } + + public List getQnaList(Long userId, Long sectionId) { + User myUser = getUserById(userId); + if (sectionId < 1L || sectionId > 5L) { + throw new CustomException(ErrorType.NOT_FOUND_SECTION); + } + + Parentchild parentchild = getParentchildByUser(myUser); + List qnaList = getQnAListByParentchild(parentchild); + + QnA todayQnA = getTodayQnAByParentchild(parentchild); + int doneIndex = parentchild.getCount() - 1; + if (todayQnA.isChildAnswer() && todayQnA.isParentAnswer()) { + doneIndex += 1; + } + + return qnaList.stream() + .limit(doneIndex) // 현재 답변 완료된 index까지 보이도록 + .filter(qna -> Objects.equals(qna.getQuestion().getSection().getSectionId(), sectionId)) + .map(qna -> { + return QnAListResponseDto.builder() + .qnaId(qna.getId()) + .index(qnaList.indexOf(qna) + 1) + .topic(qna.getQuestion().getTopic()) + .build(); + }) + .collect(Collectors.toList()); + } + + public SingleQnAResponseDto getSingleQna(Long userId, Long qnaId) { + User myUser = getUserById(userId); + Parentchild parentchild = getParentchildByUser(myUser); + + User opponentUser = getOpponentByParentchild(parentchild, userId); + QnA targetQnA = getQnAById(qnaId); + Question todayQuestion = targetQnA.getQuestion(); + + List qnaList = getQnAListByParentchild(parentchild); + + return SingleQnAResponseDto.of(myUser, opponentUser, qnaList.indexOf(targetQnA) + 1, targetQnA, todayQuestion); + } + + @Transactional + public void filterFirstQuestion(Long userId) { + + Parentchild parentchild = getUserById(userId).getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + // 첫번째 질문은 MVP 단에서는 고정 + QnA newQnA = QnA.builder() + .question(questionRepository.findByType(FIX).get(0)) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + qnARepository.save(newQnA); + + parentchild.setQna(newQnA); + } + + @Transactional + public void filterAllQuestion(Long userId) { + + Parentchild parentchild = getUserById(userId).getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + List childList = parentchild.getChildOnboardingAnswerList(); + List parentList = parentchild.getParentOnboardingAnswerList(); + + // 커스텀되기 전의 메인 질문 리스트를 가져옴 + if (parentchild.getQnaList().size() == 1) { + List mainQuestions = questionRepository.findByTypeOrderBySectionId(MAIN); + for (Question mainQuestion : mainQuestions) { + QnA newQnA = QnA.builder() + .question(mainQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + qnARepository.save(newQnA); + parentchild.setQna(newQnA); + } + } + + if (childList.size() >= 5 && parentList.size() >= 5) { + // 선택 질문에 따라 질문 리스트가 커스텀됨 + customQuestion(childList, parentList, parentchild.getQnaList()); + } + + log.info("선택된 질문 리스트"); + List forLogging= parentchild.getQnaList(); + for (QnA qnA : forLogging) { + log.info(qnA.getQuestion().getParentQuestion()); + } + + // 가까워지기 QnA도 추가 + if (parentchild.getCloserQnaList().isEmpty()) { + CloserQuestion firstCloserQuestion = closerQuestionRepository.findRandomExceptIds(new ArrayList<>()) + .orElseThrow(() -> new CustomException(ErrorType.NO_MORE_CLOSER_QUESTION)); + + CloserQnA newCloserQnA = CloserQnA.builder() + .closerQuestion(firstCloserQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + closerQnARepository.save(newCloserQnA); + parentchild.addCloserQna(newCloserQnA); + } + } + + // 마이페이지 - 부모자식 관계 정보 조회 + public MyUserInfoResponseDto getUserInfo(final Long userId) { + + User myUser = getUserById(userId); + Parentchild parentchild = getParentchildByUser(myUser); + List opponentUserList = userRepository.findUserByParentChild(parentchild) + .stream() + .filter(user -> !user.getId().equals(userId)) + .collect(Collectors.toList()); + + // 매칭된 상대 유저가 없는 경우 + if (opponentUserList.isEmpty()) { + return MyUserInfoResponseDto.of(myUser, parentchild); + } + + User opponentUser = getOpponentByParentchild(parentchild, userId); + QnA todayQnA = getTodayQnAByParentchild(parentchild); + + int qnaCnt = parentchild.getCount(); + if (!todayQnA.isChildAnswer() || !todayQnA.isParentAnswer()) { + qnaCnt -= 1; + } + + LocalDateTime firstQnADate = parentchild.getQnaList().get(0).getCreatedAt(); + long qnaDate = ChronoUnit.DAYS.between(firstQnADate, LocalDateTime.now()); + + return MyUserInfoResponseDto.of(myUser, opponentUser, parentchild, todayQnA, qnaDate, qnaCnt); + } + + /* + 리팩토링을 위해 아래로 뺀 메서드들 + */ + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorType.INVALID_USER)); + } + + private Parentchild getParentchildByUser(User user) { + Parentchild parentchild = user.getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + return parentchild; + } + + + private List getQnAListByParentchild(Parentchild parentchild) { + List qnaList = parentchild.getQnaList(); + if (qnaList == null || qnaList.isEmpty()) { + throw new CustomException(ErrorType.PARENTCHILD_HAVE_NO_QNALIST); + } + + return qnaList; + } + + private QnA getTodayQnAByParentchild(Parentchild parentchild) { + List qnaList = parentchild.getQnaList(); + if (qnaList == null || qnaList.isEmpty()) { + throw new CustomException(ErrorType.PARENTCHILD_HAVE_NO_QNALIST); + } + + return qnaList.get(parentchild.getCount() - 1); // 가장 최근의 QnA를 가져옴 + } + + private QnA getQnAById(Long qnaId) { + return qnARepository.findQnAById(qnaId) + .orElseThrow(() -> new CustomException(ErrorType.NOT_FOUND_QNA)); + } + + private User getOpponentByParentchild(Parentchild parentchild, Long userId) { + // Parentchild에 속한 User들 중 자신이 아닌 객체를 가져옴 + List opponentUserList = userRepository.findUserByParentChild(parentchild) + .stream() + .filter(user -> !user.getId().equals(userId)) + .collect(Collectors.toList()); + if (opponentUserList.isEmpty()) { + throw new CustomException(ErrorType.PARENTCHILD_HAVE_NO_OPPONENT); + } + + return opponentUserList.get(0); + } + + @Transactional + public void customQuestion(List childList, List parentList, List qnAList) { + + // Type 1 : 1번째 선택 질문인 거주 현황에 대해 한명이라도 아니/애매해라고 답한 경우 + if (childList.get(0) != YES || parentList.get(0) !=YES) { + log.info("Type1의 질문 세트가 적용됨"); + Question selectedQuestion = questionRepository.findBySectionAndTypeRandom(SCHOOL, TYPE1, 1).get(0); + log.debug("이거 들어감: " + selectedQuestion.getParentQuestion()); + qnAList.get(1).changeQuestion(selectedQuestion); + } + // Type 2 : 2번째 선택 질문인 학력 현황에 대해 한명이라도 아니/애매해라고 답한 경우 + else if (childList.get(1) != YES || parentList.get(1) != YES) { + log.info("Type2의 질문 세트가 적용됨"); + Question selectedQuestion = questionRepository.findBySectionAndTypeRandom(SCHOOL, TYPE2, 1).get(0); + log.debug("이거 들어감: " + selectedQuestion.getParentQuestion()); + qnAList.get(1).changeQuestion(selectedQuestion); + } + + // Type 3 : 3번째 선택 질문인 결혼 가치관에 대해 한명이라도 아니라고 답한 경우 + if (childList.get(2) == NO || parentList.get(2) == NO) { + log.info("Type3의 질문 세트가 적용됨"); + Question selectedQuestion = questionRepository.findBySectionAndTypeRandom(COUPLE, TYPE3, 1).get(0); + log.debug("이거 들어감: " + selectedQuestion.getParentQuestion()); + qnAList.get(4).changeQuestion(selectedQuestion); + } + + // Type 5 : 5번째 선택 질문인 후회 여부에 대해 한명이라도 아니라고 답한 경우 + if (childList.get(4) == NO || parentList.get(4) == NO) { + log.info("Type5의 질문 세트가 적용됨"); + Question selectedQuestion = questionRepository.findBySectionAndTypeRandom(GOLDEN, TYPE5, 1).get(0); + log.debug("이거 들어감: " + selectedQuestion.getParentQuestion()); + qnAList.get(3).changeQuestion(selectedQuestion); + } + // Type 4 : 4번째 선택 질문인 포기 여부에 대해 한명이라도 아니/애매해라고 답한 경우 + else if (childList.get(3) != YES || parentList.get(3) != YES) { + log.info("Type4의 질문 세트가 적용됨"); + Question selectedQuestion = questionRepository.findBySectionAndTypeRandom(GOLDEN, TYPE4, 1).get(0); + log.debug("이거 들어감: " + selectedQuestion.getParentQuestion()); + qnAList.get(3).changeQuestion(selectedQuestion); + + selectedQuestion = questionRepository.findBySectionAndTypeRandom(MARRIAGE, TYPE4, 1).get(0); + log.debug("이거 들어감: " + selectedQuestion.getParentQuestion()); + qnAList.get(5).changeQuestion(selectedQuestion); + + selectedQuestion = questionRepository.findBySectionAndTypeRandom(MARRIAGE2, TYPE4, 1).get(0); + log.debug("이거 들어감: " + selectedQuestion.getParentQuestion()); + qnAList.get(6).changeQuestion(selectedQuestion); + } + } + + + // 메인페이지 정보 + public GetMainViewResponseDto getMainInfo(Long userId) { + + // updateUserFirstEntry(userId); + User user = getUserById(userId); + Parentchild parentchild = user.getParentChild(); + List qnaList = getQnAListByParentchild(parentchild); + + QnA currentQnA = qnaList.get(parentchild.getCount()-1); + log.info("getCount(): {}", parentchild.getCount()); + + if (parentchild.getCount() == 7 && (currentQnA.isParentAnswer() && currentQnA.isChildAnswer()) && !user.isEndingDone()) { + return GetMainViewResponseDto.of(currentQnA, -1); // 유효하지 않은 -1로 반환 시 엔딩이벤트 + } else if (parentchild.getCount() == 8) { + QnA lastQnA = qnaList.get(6); + if ((lastQnA.isParentAnswer() && lastQnA.isChildAnswer()) && !user.isEndingDone()) { + return GetMainViewResponseDto.of(currentQnA, -1); + } + } + + return GetMainViewResponseDto.of(currentQnA, parentchild.getCount()); + } + + @Transactional + public FirstEntryResponseDto updateUserFirstEntry(Long userId) { + User user = getUserById(userId); + if (!user.isFirstEntry()) { + return FirstEntryResponseDto.of(false); + } + user.updateIsFirstEntry(); + return FirstEntryResponseDto.of(true); + } + + @Transactional + public void restartQna(Long userId) { + User user = getUserById(userId); + user.updateIsEndingDone(); + Parentchild parentchild = user.getParentChild(); + + if (parentchild.getCount() == 8) { + // 상대측이 이미 답변 이어가기를 호출했다면 실행할 필요 X + return; + } + + List qnaList = getQnAListByParentchild(parentchild); + + // 1. 메인 타입과 미사용 타입에 대해서 불러오기 + List types = Arrays.asList(MAIN, YET); + + // 2. 내가 이미 주고받은 질문 제외하기 + List doneQuestionIds = qnaList.stream() + .map(qna -> qna.getQuestion().getId()) + .collect(Collectors.toList()); + + // 2.5 새로고침으로 버린 블랙리스트 질문 제외하기 + doneQuestionIds.addAll(parentchild.getQuestionBlackList()); + + // 5. 이 경우 아예 추가될 질문이 없으므로 예외 발생시킴 + List targetQuestions = questionRepository.findByTypeInAndIdNotIn(types, doneQuestionIds); + if (targetQuestions.isEmpty()) { + throw new CustomException(NEED_MORE_QUESTION); + } + + QuestionSection section = qnaList.get(parentchild.getCount() - 1).getQuestion().getSection(); + List differentSectionQuestions = targetQuestions.stream() + .filter(question -> !question.getSection().equals(section)) + .collect(Collectors.toList()); + + Random random = new Random(); + Question randomQuestion; + if (!differentSectionQuestions.isEmpty()) { + // 3. 최근에 주고받은 질문의 section과 다른 질문들 중에서 랜덤하게 추출 + randomQuestion = differentSectionQuestions.get(random.nextInt(differentSectionQuestions.size())); + } else { + // 4. 없다면 동일한 section의 질문 중에서라도 랜덤하게 추출 + List equalSectionQuestions = targetQuestions.stream() + .filter(question -> question.getSection().equals(section)) + .collect(Collectors.toList()); + randomQuestion = equalSectionQuestions.get(random.nextInt(equalSectionQuestions.size())); + } + + // 새로운 질문 추가! + QnA newQnA = QnA.builder() + .question(randomQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + qnARepository.save(newQnA); + parentchild.addQna(newQnA); + parentchild.addCount(); + } + + public RerollCheckResponseDto rerollCheck(Long userId) { + User user = getUserById(userId); + Parentchild parentchild = user.getParentChild(); + + // 7일 이후가 아닌 경우 질문 새로고침 불가능 + if (parentchild.getCount() <= 7) { + throw new CustomException(ErrorType.INVALID_REROLL_BEFORE_SEVEN); + } + // 답변이 진행됐을 경우 질문 새로고침 불가능 + List qnaList = parentchild.getQnaList(); + QnA currentQnA = qnaList.get(parentchild.getCount() - 1); + if (currentQnA.isParentAnswer() || currentQnA.isChildAnswer()) { + throw new CustomException(ErrorType.INVALID_REROLL_AFTER_ANSWER); + } + + // 1. 메인 타입과 미사용 타입에 대해서 불러오기 + List types = Arrays.asList(MAIN, YET); + + // 2. 내가 이미 주고받은 질문 제외하기 + List doneQuestionIds = qnaList.stream() + .map(qna -> qna.getQuestion().getId()) + .collect(Collectors.toList()); + + // 2.5 새로고침으로 버린 블랙리스트 질문 제외하기 + doneQuestionIds.addAll(parentchild.getQuestionBlackList()); + + // 5. 이 경우 아예 추가될 질문이 없으므로 예외 발생시킴 + List targetQuestions = questionRepository.findByTypeInAndIdNotIn(types, doneQuestionIds); + if (targetQuestions.isEmpty()) { + throw new CustomException(NEED_MORE_QUESTION); + } + + QuestionSection section = qnaList.get(parentchild.getCount() - 1).getQuestion().getSection(); + List equalSectionQuestions = targetQuestions.stream() + .filter(question -> question.getSection().equals(section)) + .collect(Collectors.toList()); + + Random random = new Random(); + Question randomQuestion; + if (!equalSectionQuestions.isEmpty()) { + // 3. 최근에 주고받은 질문의 section과 같은 질문들 중에서 랜덤하게 추출 + randomQuestion = equalSectionQuestions.get(random.nextInt(equalSectionQuestions.size())); + } else { + // 4. 없다면 다른 section의 질문 중에서라도 랜덤하게 추출 + List differentSectionQuestions = targetQuestions.stream() + .filter(question -> !question.getSection().equals(section)) + .collect(Collectors.toList()); + randomQuestion = differentSectionQuestions.get(random.nextInt(differentSectionQuestions.size())); + } + + return RerollCheckResponseDto.of(user.isMeChild(), randomQuestion); + } + + @Transactional + public void rerollChange(Long userId, Long questionId) { + User user = getUserById(userId); + Question question = questionRepository.findById(questionId) + .orElseThrow(() -> new CustomException(ErrorType.NOT_FOUND_QUESTION)); + + // 하루에 한번만 질문 새로고침 가능 + LocalDateTime lastRerollChange = user.getLastRerollChange(); + LocalDateTime now = LocalDateTime.now(); + if (lastRerollChange != null) { + Duration duration = Duration.between(lastRerollChange, now); + long hoursPassed = duration.toHours(); + + if (hoursPassed < 24) { + throw new CustomException(ErrorType.INVALID_REROLL_ONCE_A_DAY); + } + } + + Parentchild parentchild = user.getParentChild(); + List qnaList = parentchild.getQnaList(); + QnA currentQnA = qnaList.get(parentchild.getCount() - 1); + + // 새로고침으로 버린 질문은 블랙리스트에 추가 + parentchild.addQuestionBlackList(currentQnA.getQuestion().getId()); + currentQnA.changeQuestion(question); + user.updateLastRerollChange(); + } + + @NotNull + private Parentchild getParentchild(Long userId) { + Parentchild parentchild = getUserById(userId).getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + return parentchild; + } + + private GetInvitationResponseDto invitation(Long userId) { + + User user = getUserById(userId); + Parentchild parentchild = parentchildDao.findByUserId(userId).orElseThrow( + () -> new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD) + ); + + return GetInvitationResponseDto.of(parentchild.getInviteCode(), user.getUsername(), "http://umbba.site/"); // TODO Firebase 동적링크 연결 예정 + } + + private GetInvitationResponseDto withdrawUser() { + return GetInvitationResponseDto.of(false); + } + + private GetInvitationResponseDto firstTutorialQnA() { + return GetInvitationResponseDto.ofFirst(false); + } + + + /** + * 데모데이 테스트용 메서드 + */ + @Transactional + public void updateDemoList(Long userId) { + + User myUser = getUserById(userId); + Parentchild parentchild = getParentchildByUser(myUser); + + for (int i=0; i<4; i++) { + updateDay(parentchild, + "우리 부모님은 어렸을 때부터 행복하고 좋은 기억을 많이 주셨고, 정말 행복하게 자랐어. 그 덕에 지금까지 행복하고 안정된 느낌을 받아.", + "오구 내 똥강아지~ 어렸을 때는 매일 볼 수 있었는데, 어른이 되고 나서 자주 못봐서 너무 아쉽다... 연락 잘하거라 요녀석~"); + } + QnA fifthQnA = getTodayQnAByParentchild(parentchild); + log.info("💖💖💖💖Day 5 QnA: {}", fifthQnA.getId()); + //TODO ⭐️SQS로 변경 +// fcmService.multipleSendByToken(FCMPushRequestDto.sendTodayQna( +// fifthQnA.getQuestion().getSection().getValue(), +// fifthQnA.getQuestion().getTopic()), parentchild.getId()); + + } + + @Transactional + public void todayUpdate(Long userId) { + + User myUser = getUserById(userId); + Parentchild parentchild = getParentchildByUser(myUser); + + updateDay(parentchild, + "우리 부모님은 어렸을 때부터 행복하고 좋은 기억을 많이 주셨고, 정말 행복하게 자랐어. 그 덕에 지금까지 행복하고 안정된 느낌을 받아.", + "오구 내 똥강아지~ 어렸을 때는 매일 볼 수 있었는데, 어른이 되고 나서 자주 못봐서 너무 아쉽다... 연락 잘하거라 요녀석~"); + + QnA todayQnA = getTodayQnAByParentchild(parentchild); + //TODO ⭐️SQS로 변경 +// fcmService.multipleSendByToken(FCMPushRequestDto.sendTodayQna( +// todayQnA.getQuestion().getSection().getValue(), +// todayQnA.getQuestion().getTopic()), parentchild.getId()); + } + + private void updateDay(Parentchild parentchild, String childAnswer, String parentAnswer) { + QnA currentQnA = getTodayQnAByParentchild(parentchild); + log.info("💖💖💖💖Current QnA: {}", currentQnA.getId()); + currentQnA.saveChildAnswer(childAnswer); + currentQnA.saveParentAnswer(parentAnswer); + parentchild.addCount(); + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/user/AuthService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/AuthService.java new file mode 100644 index 00000000..8a2a4682 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/AuthService.java @@ -0,0 +1,162 @@ +package sopt.org.umbba.api.service.user; + +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sopt.org.umbba.api.config.auth.UserAuthentication; +import sopt.org.umbba.api.config.jwt.JwtProvider; +import sopt.org.umbba.api.config.jwt.TokenDto; +import sopt.org.umbba.api.controller.user.dto.request.RefreshRequestDto; +import sopt.org.umbba.api.controller.user.dto.request.SocialLoginRequestDto; +import sopt.org.umbba.api.controller.user.dto.response.UserLoginResponseDto; +import sopt.org.umbba.api.service.user.social.apple.AppleLoginService; +import sopt.org.umbba.api.service.user.social.kakao.KakaoLoginService; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.parentchild.repository.ParentchildRepository; +import sopt.org.umbba.domain.domain.qna.repository.QnARepository; +import sopt.org.umbba.domain.domain.user.SocialPlatform; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; + +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final ParentchildRepository parentchildRepository; + private final QnARepository qnARepository; + + private final AppleLoginService appleLoginService; + private final KakaoLoginService kakaoLoginService; + + @Transactional + public UserLoginResponseDto login(String socialAccessToken, SocialLoginRequestDto request) throws NoSuchAlgorithmException, InvalidKeySpecException { + + SocialPlatform socialPlatform = SocialPlatform.of(request.getSocialPlatform()); + String socialId = login(socialPlatform, socialAccessToken); + + boolean isRegistered = isUserBySocialAndSocialId(socialPlatform, socialId); + + if (!isRegistered) { + + User user = User.builder() + .socialPlatform(socialPlatform) + .socialId(socialId) + .isMeChild(true) + .isMatchFinish(false) + .fcmToken(request.getFcmToken()) + .isFirstEntry(true) + .build(); + + userRepository.save(user); + } + + User loginUser = getUserBySocialAndSocialId(socialPlatform, socialId); + + // 카카오는 정보 더 많이 받아올 수 있으므로 추가 설정 + if (socialPlatform == SocialPlatform.KAKAO) { + kakaoLoginService.setKakaoInfo(loginUser, socialAccessToken); + } + + TokenDto tokenDto = jwtProvider.issueToken(new UserAuthentication(loginUser.getId(), null, null)); + loginUser.updateRefreshToken(tokenDto.getRefreshToken()); + + // 클라이언트 요청에 따라 FCM 토큰을 로그인할 때마다 업데이트 하도록 변경 + loginUser.updateFcmToken(request.getFcmToken()); + log.info("🔮{}의 JWT Access Token: {}", loginUser.getUsername(), tokenDto.getAccessToken()); + + return UserLoginResponseDto.of(loginUser, tokenDto.getAccessToken()); + } + + @Transactional + public TokenDto reissueToken(RefreshRequestDto request, String refreshToken) throws Exception { + + Long userId = request.getUserId(); + User user = getUserById(userId); // userId가 DB에 저장된 유효한 값인지 검사 + + if (!jwtProvider.validateRefreshToken(request.getUserId(), refreshToken)) { + throw new CustomException(ErrorType.NOT_MATCH_REFRESH_TOKEN); + } + + TokenDto reissuedToken = jwtProvider.issueToken(new UserAuthentication(userId, null, null)); + user.updateRefreshToken(reissuedToken.getRefreshToken()); + return reissuedToken; + } + + @Transactional + public void logout(Long userId) { + User user = getUserById(userId); + user.updateRefreshToken(null); + jwtProvider.deleteRefreshToken(userId); + } + + @Transactional + public void signout(Long userId) { + User user = getUserById(userId); + user.updateRefreshToken(null); + user.updateFcmToken(null); + user.deleteSocialInfo(); + jwtProvider.deleteRefreshToken(userId); // 일치하는 ID가 없는 경우에는 아무 동작도 수행하지 않음 (CrudRepository 기본 동작) + + Parentchild parentChild = user.getParentChild(); + List findUsers = userRepository.findUserByParentChild(parentChild); + + boolean allUsersDeleted = findUsers.stream() + .allMatch(u -> u.getSocialPlatform().equals(SocialPlatform.WITHDRAW)); + if (allUsersDeleted) { + if (findUsers.size() == 1) { + log.info("삭제된 유저: {}", findUsers.get(0).getUsername()); + } else if (findUsers.size() == 2) { + log.info("삭제된 부모자식: {} X {}", findUsers.get(0).getUsername(), findUsers.get(1).getUsername()); + } + parentChild.getQnaList().forEach(qna -> qnARepository.deleteById(qna.getId())); + parentchildRepository.deleteById(parentChild.getId()); + findUsers.forEach(u -> userRepository.deleteById(u.getId())); + } + } + + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorType.INVALID_USER)); + } + + private User getUserBySocialAndSocialId(SocialPlatform socialPlatform, String socialId) { + List users = userRepository.findBySocialPlatformAndSocialId(socialPlatform, socialId); + if (users.isEmpty()) { + throw new CustomException(ErrorType.INVALID_USER); + } + + return users.get(0); + } + + private boolean isUserBySocialAndSocialId(SocialPlatform socialPlatform, String socialId) { + return userRepository.existsBySocialPlatformAndSocialId(socialPlatform, socialId); + } + + private String login(SocialPlatform socialPlatform, String socialAccessToken) { + + try { + switch (socialPlatform.toString()) { + case "APPLE": + return appleLoginService.getAppleId(socialAccessToken); + case "KAKAO": + return kakaoLoginService.getKakaoId(socialAccessToken); + default: + throw new CustomException(ErrorType.INVALID_SOCIAL_PLATFORM); + } + } catch (FeignException e) { + throw new CustomException(ErrorType.INVALID_SOCIAL_ACCESS_TOKEN); + } + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/AppleLoginService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/AppleLoginService.java new file mode 100644 index 00000000..d4624410 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/AppleLoginService.java @@ -0,0 +1,44 @@ +package sopt.org.umbba.api.service.user.social.apple; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import sopt.org.umbba.api.service.user.social.apple.verify.AppleClaimsValidator; +import sopt.org.umbba.api.service.user.social.apple.verify.AppleJwtParser; +import sopt.org.umbba.api.service.user.social.apple.verify.PublicKeyGenerator; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.external.client.auth.apple.AppleApiClient; +import sopt.org.umbba.external.client.auth.apple.response.ApplePublicKeys; + + +import java.security.PublicKey; +import java.util.Map; + +@RequiredArgsConstructor +@Service +public class AppleLoginService { + + private final AppleApiClient appleApiClient; + private final AppleJwtParser appleJwtParser; + private final PublicKeyGenerator publicKeyGenerator; + private final AppleClaimsValidator appleClaimsValidator; + + public String getAppleId(String identityToken) { + Map headers = appleJwtParser.parseHeaders(identityToken); + ApplePublicKeys applePublicKeys = appleApiClient.getApplePublicKeys(); + + PublicKey publicKey = publicKeyGenerator.generatePublicKey(headers, applePublicKeys); + + Claims claims = appleJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey); + validateClaims(claims); + return claims.getSubject(); + } + + private void validateClaims(Claims claims) { + if (!appleClaimsValidator.isValid(claims)) { + throw new CustomException(ErrorType.INVALID_APPLE_CLAIMS); + } + } + +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/AppleClaimsValidator.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/AppleClaimsValidator.java new file mode 100644 index 00000000..f25deb13 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/AppleClaimsValidator.java @@ -0,0 +1,30 @@ +package sopt.org.umbba.api.service.user.social.apple.verify; + +import io.jsonwebtoken.Claims; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AppleClaimsValidator { + + private final String iss; + private final String clientId; +// private final String nonce; // iOS 멘토링에서 질문 후 사용 여부 결정 + + public AppleClaimsValidator( + @Value("${apple.iss}") String iss, + @Value("${apple.client-id}") String clientId +// @Value("${apple.nonce}") String nonce + ) { + this.iss = iss; + this.clientId = clientId; +// this.nonce = EncryptUtils.encrypt(nonce); + } + + public boolean isValid(Claims claims) { + return claims.getIssuer().contains(iss) + && claims.getAudience().equals(clientId); +// && claims.get(NONCE_KEY, String.class).equals(nonce); + } +} + diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/AppleJwtParser.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/AppleJwtParser.java new file mode 100644 index 00000000..33942031 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/AppleJwtParser.java @@ -0,0 +1,47 @@ +package sopt.org.umbba.api.service.user.social.apple.verify; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.*; +import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; + +import java.security.PublicKey; +import java.util.Map; + +@Component +public class AppleJwtParser { + + private static final String IDENTITY_TOKEN_VALUE_DELIMITER = "\\."; + private static final int HEADER_INDEX = 0; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public Map parseHeaders(String identityToken) { + try { + String encodedHeader = identityToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX]; + String decodedHeader = new String(Base64Utils.decodeFromUrlSafeString(encodedHeader)); + return OBJECT_MAPPER.readValue(decodedHeader, Map.class); + + } catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) { + throw new CustomException(ErrorType.INVALID_APPLE_IDENTITY_TOKEN); + } + } + + public Claims parsePublicKeyAndGetClaims(String idToken, PublicKey publicKey) { + try { + return Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(idToken) + .getBody(); + + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorType.EXPIRED_APPLE_IDENTITY_TOKEN); + } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) { + throw new CustomException(ErrorType.INVALID_APPLE_IDENTITY_TOKEN); + } + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/EncryptUtils.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/EncryptUtils.java new file mode 100644 index 00000000..2211ed45 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/EncryptUtils.java @@ -0,0 +1,26 @@ +package sopt.org.umbba.api.service.user.social.apple.verify; + + +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class EncryptUtils { + + public static String encrypt(String value) { + try { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] digest = sha256.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : digest) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new CustomException(ErrorType.INVALID_ENCRYPT_COMMUNICATION); + } + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/PublicKeyGenerator.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/PublicKeyGenerator.java new file mode 100644 index 00000000..5e86e6e1 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/apple/verify/PublicKeyGenerator.java @@ -0,0 +1,49 @@ +package sopt.org.umbba.api.service.user.social.apple.verify; + +import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.external.client.auth.apple.response.ApplePublicKey; +import sopt.org.umbba.external.client.auth.apple.response.ApplePublicKeys; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Map; + +@Component +public class PublicKeyGenerator { + + private static final String SIGN_ALGORITHM_HEADER_KEY = "alg"; + private static final String KEY_ID_HEADER_KEY = "kid"; + private static final int POSITIVE_SIGN_NUMBER = 1; + + public PublicKey generatePublicKey(Map headers, ApplePublicKeys applePublicKeys) { + ApplePublicKey applePublicKey = + applePublicKeys.getMatchesKey(headers.get(SIGN_ALGORITHM_HEADER_KEY), headers.get(KEY_ID_HEADER_KEY)); + + return generatePublicKeyWithApplePublicKey(applePublicKey); + } + + private PublicKey generatePublicKeyWithApplePublicKey(ApplePublicKey publicKey) { + byte[] nBytes = Base64Utils.decodeFromUrlSafeString(publicKey.getN()); + byte[] eBytes = Base64Utils.decodeFromUrlSafeString(publicKey.getE()); + + BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes); + BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + + try { + KeyFactory keyFactory = KeyFactory.getInstance(publicKey.getKty()); + return keyFactory.generatePublic(publicKeySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) { + throw new CustomException(ErrorType.CREATE_PUBLIC_KEY_EXCEPTION); + } + } +} + diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/kakao/KakaoLoginService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/kakao/KakaoLoginService.java new file mode 100644 index 00000000..e04aeb9c --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/user/social/kakao/KakaoLoginService.java @@ -0,0 +1,62 @@ +package sopt.org.umbba.api.service.user.social.kakao; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.external.client.auth.kakao.KakaoApiClient; +import sopt.org.umbba.external.client.auth.kakao.KakaoAuthApiClient; +import sopt.org.umbba.external.client.auth.kakao.response.KakaoAccessTokenResponse; +import sopt.org.umbba.external.client.auth.kakao.response.KakaoUserResponse; + +@Service +@Transactional +@RequiredArgsConstructor +public class KakaoLoginService { + + @Value("${kakao.client-id}") + private String CLIENT_ID; + @Value("${kakao.authorization-grant-type}") + private String GRANT_TYPE; + @Value("${kakao.redirect-uri}") + private String REDIRECT_URL; + + private final KakaoAuthApiClient kakaoAuthApiClient; + private final KakaoApiClient kakaoApiClient; + + public String getKakaoAccessToken(String code) { + // Authorization code로 Access Token 불러오기 + KakaoAccessTokenResponse tokenResponse = kakaoAuthApiClient.getOAuth2AccessToken( + GRANT_TYPE, + CLIENT_ID, + REDIRECT_URL, + code + ); + return tokenResponse.getAccessToken(); + // Refresh 토큰은 필요 없는거 맞나? + // 1. 만약 필요하다면 클라한테서 함께 받아온다음 + // 2. login 메서드 -> setKakaoInfo 메서드 호출할 때 같이 받아오는 작업 필요 + } + + public String getKakaoId(String socialAccessToken) { + + // Access Token으로 유저 정보 불러오기 + KakaoUserResponse userResponse = kakaoApiClient.getUserInformation("Bearer " + socialAccessToken); + + String kakaoId = Long.toString(userResponse.getId()); //Social ID를 조회 + + return kakaoId; + } + + public void setKakaoInfo(User loginUser, String socialAccessToken) { + + // Access Token으로 유저 정보 불러오기 + KakaoUserResponse userResponse = kakaoApiClient.getUserInformation("Bearer " + socialAccessToken); + + loginUser.updateSocialInfo(userResponse.getKakaoAccount().getProfile().getNickname(), + userResponse.getKakaoAccount().getProfile().getProfileImageUrl(), + socialAccessToken); //Kakao의 Access 토큰도 매번 업데이트 + } + +} \ No newline at end of file diff --git a/umbba-api/src/main/resources/application-dev1.yml b/umbba-api/src/main/resources/application-dev1.yml new file mode 100644 index 00000000..7bb35838 --- /dev/null +++ b/umbba-api/src/main/resources/application-dev1.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: dev1 + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_DEV} + username: ${DB_USER_DEV} + password: ${DB_PWD_DEV} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_DEV} + secretKey: ${CLOUD_SECRET_DEV} + region: + static: ${CLOUD_REGION_DEV} + s3: + bucket: ${BUCKET_NAME_DEV} + bucketImg: ${IMG_BUCKET_DEV} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_DEV} + url: ${SQS_URL_DEV} + +server: + port: 8081 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_DEV} + +slack: + webhook: + url: ${SLACK_URL_DEV} \ No newline at end of file diff --git a/umbba-api/src/main/resources/application-dev2.yml b/umbba-api/src/main/resources/application-dev2.yml new file mode 100644 index 00000000..69a59002 --- /dev/null +++ b/umbba-api/src/main/resources/application-dev2.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: dev2 + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_DEV} + username: ${DB_USER_DEV} + password: ${DB_PWD_DEV} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_DEV} + secretKey: ${CLOUD_SECRET_DEV} + region: + static: ${CLOUD_REGION_DEV} + s3: + bucket: ${BUCKET_NAME_DEV} + bucketImg: ${IMG_BUCKET_DEV} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_DEV} + url: ${SQS_URL_DEV} + +server: + port: 8082 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_DEV} + +slack: + webhook: + url: ${SLACK_URL_DEV} \ No newline at end of file diff --git a/umbba-api/src/main/resources/application-local.yml b/umbba-api/src/main/resources/application-local.yml new file mode 100644 index 00000000..ee4d0e6e --- /dev/null +++ b/umbba-api/src/main/resources/application-local.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: local + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_LOCAL} + username: ${DB_USER_LOCAL} + password: ${DB_PWD_LOCAL} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_LOCAL} + secretKey: ${CLOUD_SECRET_LOCAL} + region: + static: ${CLOUD_REGION_LOCAL} + s3: + bucket: ${BUCKET_NAME_LOCAL} + bucketImg: ${IMG_BUCKET_LOCAL} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_LOCAL} + url: ${SQS_URL_LOCAL} + +server: + port: 9091 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_LOCAL} + +slack: + webhook: + url: ${SLACK_URL_LOCAL} \ No newline at end of file diff --git a/umbba-api/src/main/resources/application-prod1.yml b/umbba-api/src/main/resources/application-prod1.yml new file mode 100644 index 00000000..e2a14e28 --- /dev/null +++ b/umbba-api/src/main/resources/application-prod1.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: prod1 + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_PROD} + username: ${DB_USER_PROD} + password: ${DB_PWD_PROD} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_PROD} + secretKey: ${CLOUD_SECRET_PROD} + region: + static: ${CLOUD_REGION_PROD} + s3: + bucket: ${BUCKET_NAME_PROD} + bucketImg: ${IMG_BUCKET_PROD} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_PROD} + url: ${SQS_URL_PROD} + +server: + port: 8081 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_PROD} + +slack: + webhook: + url: ${SLACK_URL_PROD} \ No newline at end of file diff --git a/umbba-api/src/main/resources/application-prod2.yml b/umbba-api/src/main/resources/application-prod2.yml new file mode 100644 index 00000000..bff4413a --- /dev/null +++ b/umbba-api/src/main/resources/application-prod2.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: prod2 + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_PROD} + username: ${DB_USER_PROD} + password: ${DB_PWD_PROD} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_PROD} + secretKey: ${CLOUD_SECRET_PROD} + region: + static: ${CLOUD_REGION_PROD} + s3: + bucket: ${BUCKET_NAME_PROD} + bucketImg: ${IMG_BUCKET_PROD} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_PROD} + url: ${SQS_URL_PROD} + +server: + port: 8082 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_PROD} + +slack: + webhook: + url: ${SLACK_URL_PROD} \ No newline at end of file diff --git a/umbba-api/src/main/resources/application.yml b/umbba-api/src/main/resources/application.yml new file mode 100644 index 00000000..b0c4554e --- /dev/null +++ b/umbba-api/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + profiles: + active: local + +apple: + iss: ${APPLE_ISS} + client-id: ${APPLE_ID} + +jwt: + secret: ${JWT_SECRET} + +fcm: + key: + path: ${FCM_JSON_PATH} + scope: ${FCM_SCOPE} + # firebase-create-scoped: "https://www.googleapis.com/auth/firebase.messaging" + api: + url: ${FCM_API_URL} + topic: + "qna_notification" + +logging: + level: + com: + amazonaws: + util: + EC2MetadataUtils: error \ No newline at end of file diff --git a/umbba-api/src/main/resources/bootstrap.yml b/umbba-api/src/main/resources/bootstrap.yml new file mode 100644 index 00000000..b03fe132 --- /dev/null +++ b/umbba-api/src/main/resources/bootstrap.yml @@ -0,0 +1,7 @@ +aws: + secretsmanager: + name: umbba-secret +cloud: + aws: + region: + static: ap-northeast-2 \ No newline at end of file diff --git a/umbba-common/build.gradle b/umbba-common/build.gradle new file mode 100644 index 00000000..5a66b154 --- /dev/null +++ b/umbba-common/build.gradle @@ -0,0 +1,10 @@ +bootJar { enabled = false } +jar { enabled = true } +dependencies { + + // for HttpStatus + implementation 'org.springframework.boot:spring-boot-starter-web' + + // Jackson + implementation group: "io.jsonwebtoken", name: "jjwt-jackson", version: "0.11.2" +} \ No newline at end of file diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/UmbbaCommonRoot.java b/umbba-common/src/main/java/sopt/org/umbba/common/UmbbaCommonRoot.java new file mode 100644 index 00000000..14910c5f --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/UmbbaCommonRoot.java @@ -0,0 +1,8 @@ +package sopt.org.umbba.common; + +import org.springframework.context.annotation.ComponentScan; + +@ComponentScan(basePackageClasses = {UmbbaCommonRoot.class}) +public interface UmbbaCommonRoot { + +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java new file mode 100644 index 00000000..7f062e6a --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java @@ -0,0 +1,124 @@ +package sopt.org.umbba.common.exception; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ErrorType { + + /** + * 400 BAD REQUEST + */ + + // Common + REQUEST_VALIDATION_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 요청입니다"), + VALIDATION_WRONG_TYPE_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 타입이 입력되었습니다."), + HEADER_REQUEST_MISSING_EXCEPTION(HttpStatus.BAD_REQUEST, "요청에 필요한 헤더값이 존재하지 않습니다."), + VALIDATION_WRONG_HTTP_REQUEST(HttpStatus.BAD_REQUEST, "허용되지 않는 문자열이 입력되었습니다."), + INVALID_HTTP_METHOD(HttpStatus.BAD_REQUEST, "지원되지 않는 HTTP Method 요청입니다."), + + INVALID_SOCIAL_PLATFORM(HttpStatus.BAD_REQUEST, "유효하지 않은 소셜 플랫폼입니다."), + INVALID_ONBOARDING_ANSWER(HttpStatus.BAD_REQUEST, "유효하지 않은 선택질문 답변값입니다."), + INVALID_ONBOARDING_ANSWER_SIZE(HttpStatus.BAD_REQUEST, "유효한 선택질문 답변 개수는 5개입니다."), + INVALID_SOCIALPLATFORM(HttpStatus.BAD_REQUEST, "유효하지 않은 소셜 플랫폼 문자열"), + INVALID_REMIND_TIME(HttpStatus.BAD_REQUEST, "유효하지 않은 리마인드 시간입니다."), + + // ParentChild - Onboarding + INVALID_PARENT_CHILD_RELATION_INFO(HttpStatus.BAD_REQUEST, "부모자식 관계를 정의할 수 없는 요청값입니다."), + INVALID_INVITE_CODE(HttpStatus.BAD_REQUEST, "유효하지 않는 초대코드입니다."), + INVALID_PARENT_CHILD_RELATION(HttpStatus.BAD_REQUEST, "유효하지 않는 부모자식 관계입니다."), + NOT_MATCH_PARENT_CHILD_RELATION(HttpStatus.BAD_REQUEST, "아직 부모자식 관계 매칭이 이루어지지 않았습니다."), + ALREADY_EXISTS_PARENT_CHILD_USER(HttpStatus.BAD_REQUEST, "이미 해당 유저의 부모자식 관계가 존재합니다."), + ALREADY_QNA_LIST_FULL(HttpStatus.BAD_REQUEST, "이미 QNA 리스트가 가득 찼습니다"), + + // Album + INVALID_BUCKET_PREFIX(HttpStatus.BAD_REQUEST, "유효하지 않은 S3 버킷 디렉토리명입니다."), + + // Closer + INVALID_COUNT_STATUS(HttpStatus.BAD_REQUEST, "count 조건으로 인해 다음 가까워지기 질문으로 넘어갈 수 없습니다."), + + // Reroll + INVALID_REROLL_BEFORE_SEVEN(HttpStatus.BAD_REQUEST, "7일 이후에만 질문 새로고침을 할 수 있습니다."), + INVALID_REROLL_AFTER_ANSWER(HttpStatus.BAD_REQUEST, "답변이 진행되기 이전에만 질문 새로고침을 할 수 있습니다."), + INVALID_REROLL_ONCE_A_DAY(HttpStatus.BAD_REQUEST, "하루에 한번만 질문 새로고침을 할 수 있습니다."), + + + /** + * 401 UNAUTHORIZED + */ + INVALID_SOCIAL_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 소셜 엑세스 토큰입니다."), + EMPTY_PRINCIPLE_EXCEPTION(HttpStatus.UNAUTHORIZED, "엑세스 토큰이 비어있거나, 유효하지 않은 엑세스 토큰입니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 엑세스 토큰입니다, 엑세스 토큰을 재발급 받아주세요."), + INVALID_FIREBASE_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 파이어베이스 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다, 다시 로그인을 해주세요."), + NOT_MATCH_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "일치하지 않는 리프레시 토큰입니다."), + RECEIVE_AFTER_MATCH(HttpStatus.BAD_REQUEST, "초대코드로 매칭 후에 온보딩 수신이 가능합니다."), + + + /** + * 404 NOT FOUND + */ + INVALID_USER(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."), + WITHDRAW_USER(HttpStatus.NOT_FOUND, "탈퇴한 회원입니다."), + NOT_EXIST_PARENT_CHILD_USER(HttpStatus.NOT_FOUND, "해당 부모자식 관계에 해당하는 유저가 존재하지 않습니다."), + NOT_EXIST_CHILD_USER(HttpStatus.NOT_FOUND, "해당 관계에 속하는 자식 유저가 존재하지 않습니다."), + NOT_FOUND_QNA(HttpStatus.NOT_FOUND, "해당 아이디와 일치하는 QnA 데이터가 없습니다."), + USER_HAVE_NO_PARENTCHILD(HttpStatus.NOT_FOUND, "회원이 속한 부모자식 관계가 없습니다."), + NOT_EXIST_PARENT_CHILD_RELATION(HttpStatus.NOT_FOUND, "존재하지 않는 부모자식 관계입니다."), + USER_HAVE_NO_QNALIST(HttpStatus.NOT_FOUND, "해당 유저가 가지고 있는 QnA 데이터가 없습니다."), + PARENTCHILD_HAVE_NO_QNALIST(HttpStatus.NOT_FOUND, "부모자식 관계가 가지고 있는 QnA 데이터가 없습니다."), + PARENTCHILD_HAVE_NO_OPPONENT(HttpStatus.NOT_FOUND, "부모자식 관계에 1명만 참여하고 있습니다."), + NOT_FOUND_SECTION(HttpStatus.NOT_FOUND, "해당 아이디와 일치하는 섹션이 없습니다."), + NOT_FOUND_ALBUM(HttpStatus.NOT_FOUND, "존재하지 않는 앨범입니다."), + NOT_FOUND_QUESTION(HttpStatus.NOT_FOUND, "해당 아이디와 일치하는 Question 데이터가 없습니다."), + + /** + * About Apple (HttpStatus 고민) + */ + INVALID_APPLE_PUBLIC_KEY(HttpStatus.BAD_REQUEST, "Apple JWT 값의 alg, kid 정보가 올바르지 않습니다."), + INVALID_APPLE_IDENTITY_TOKEN(HttpStatus.BAD_REQUEST, "Apple OAuth Identity Token 형식이 올바르지 않습니다."), + EXPIRED_APPLE_IDENTITY_TOKEN(HttpStatus.BAD_REQUEST, "Apple OAuth 로그인 중 Identity Token 유효기간이 만료됐습니다."), + INVALID_APPLE_CLAIMS(HttpStatus.BAD_REQUEST, "Apple OAuth Claims 값이 올바르지 않습니다."), + INVALID_ENCRYPT_COMMUNICATION(HttpStatus.BAD_REQUEST, "Apple OAuth 통신 암호화 과정 중 문제가 발생했습니다."), + CREATE_PUBLIC_KEY_EXCEPTION(HttpStatus.BAD_REQUEST, "Apple OAuth 로그인 중 public verify 생성에 문제가 발생했습니다."), + + + /** + * 500 INTERNAL SERVER ERROR + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 서버 에러가 발생했습니다"), + DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "데이터베이스 관련 에러가 발생했습니다."), + FIREBASE_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파이어베이스 서버와의 연결에 실패했습니다."), + FAIL_TO_SEND_PUSH_ALARM(HttpStatus.INTERNAL_SERVER_ERROR, "푸시 알림 메세지 전송에 실패했습니다."), + FAIL_TO_GET_IMAGE_PRE_SIGNED_URL(HttpStatus.INTERNAL_SERVER_ERROR, "PreSigned Url을 가져오는 데 실패했습니다."), + FAIL_TO_DELETE_IMAGE(HttpStatus.INTERNAL_SERVER_ERROR, "S3 버킷에서 이미지를 삭제하는 데 실패했습니다."), + S3_BUCKET_GET_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 버킷에서 이미지를 불러오는 데 실패했습니다."), + + // ETC + INDEX_OUT_OF_BOUNDS(HttpStatus.INTERNAL_SERVER_ERROR, "인덱스 범위를 초과했습니다."), + JWT_SERIALIZE(HttpStatus.INTERNAL_SERVER_ERROR, "JWT 라이브러리 직렬화에 실패했습니다."), + OPTIONAL_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, "Optional 객체가 비어있습니다."), + NON_UNIQUE_RESULT_OF_QUERY(HttpStatus.INTERNAL_SERVER_ERROR, "JPA 쿼리가 유일한 결과를 반환하지 않습니다."), + INTERNAL_SERVLET_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "디스패처 서블릿이 요청을 처리하는 중에 예외가 발생했습니다."), + NO_ENUM_TYPE(HttpStatus.INTERNAL_SERVER_ERROR, "해당 Enum 타입이 데이터베이스 엔티티와 매핑될 수 없습니다."), + DATA_INTEGRITY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "데이터 무결성 제약조건을 위반했습니다."), + NULL_POINTER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NULL 포인터를 참조했습니다."), + + /** + * 501 NOT_IMPLEMENTED + */ + NEED_MORE_QUESTION(HttpStatus.NOT_IMPLEMENTED, "남은 질문이 없습니다. 질문을 추가해주세요."), + NO_MORE_CLOSER_QUESTION(HttpStatus.NOT_IMPLEMENTED, "남은 가까워지기 질문이 없습니다."), + MAX_LIMIT_ALBUM_UPLOAD(HttpStatus.NOT_IMPLEMENTED, "한 부모자식마다 최대 15개의 업로드까지만 허용합니다."), + ; + + private final HttpStatus httpStatus; + private final String message; + + public int getHttpStatusCode() { + return httpStatus.value(); + } +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java new file mode 100644 index 00000000..d5392967 --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java @@ -0,0 +1,58 @@ +package sopt.org.umbba.common.exception; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum SuccessType { + + /** + * 200 OK + */ + LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공했습니다."), + REISSUE_SUCCESS(HttpStatus.OK, "Access 토큰 재발급에 성공했습니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃에 성공했습니다."), + SIGNOUT_SUCCESS(HttpStatus.OK, "회원탈퇴에 성공했습니다."), + KAKAO_ACCESS_TOKEN_SUCCESS(HttpStatus.OK, "카카오 엑세스 토큰을 가져오는데 성공했습니다"), + GET_TODAY_QNA_SUCCESS(HttpStatus.OK, "일일문답 조회에 성공했습니다."), + GET_QNA_LIST_SUCCESS(HttpStatus.OK, "섹션별 과거의 문답 리스트 조회에 성공했습니다."), + GET_SINGLE_QNA_SUCCESS(HttpStatus.OK, "과거의 문답 개별 조회에 성공했습니다."), + GET_MAIN_HOME_SUCCESS(HttpStatus.OK, "메인 홈 화면 정보 불러오기에 성공했습니다."), + GET_INVITE_CODE_SUCCESS(HttpStatus.OK, "초대장을 보낼 코드 조회에 성공했습니다."), + PUSH_ALARM_SUCCESS(HttpStatus.OK, "푸시알림 전송에 성공했습니다."), + PUSH_ALARM_PERIODIC_SUCCESS(HttpStatus.OK, "오늘의 질문 푸시알림 활성에 성공했습니다."), + REMIND_QUESTION_SUCCESS(HttpStatus.OK, "상대방에게 질문을 리마인드 하는 데 성공했습니다."), + GET_MY_USER_INFO_SUCCESS(HttpStatus.OK, "마이페이지 내 정보 조회에 성공했습니다."), + TEST_SUCCESS(HttpStatus.OK, "데모데이 테스트용 API 호출에 성공했습니다."), + RESTART_QNA_SUCCESS(HttpStatus.OK, "7일 이후 문답이 정상적으로 시작되었습니다."), + GET_USER_FIRST_ENTRY_SUCCESS(HttpStatus.OK, "유저의 첫 진입여부 조회에 성공했습니다."), + GET_PRE_SIGNED_URL_SUCCESS(HttpStatus.OK, "PreSigned Url 조회에 성공했습니다."), + IMAGE_S3_DELETE_SUCCESS(HttpStatus.OK, "S3 버킷에서 이미지를 삭제하는 데 성공했습니다."), + DELETE_ALBUM_SUCCESS(HttpStatus.OK, "앨범의 기록 삭제에 성공했습니다."), + GET_ALBUM_LIST_SUCCESS(HttpStatus.OK, "앨범의 기록 목록 조회에 성공했습니다."), + GET_TODAY_CLOSER_QNA_SUCCESS(HttpStatus.OK, "오늘의 가까워지기 문답 조회에 성공했습니다."), + ANSWER_TODAY_CLOSER_QUESTION_SUCCESS(HttpStatus.OK, "오늘의 가까워지기 문답에 답변을 완료하였습니다."), + PASS_TO_NEXT_CLOSER_QUESTION_SUCCESS(HttpStatus.OK, "다음 가까워지기 문답으로 넘어가는 데에 성공했습니다."), + GET_REROLL_CHECK_SUCCESS(HttpStatus.OK, "새로고침 할 수 있는 질문 조회에 성공했습니다."), + REROLL_CHANGE_SUCCESS(HttpStatus.OK, "질문 새로고침이 완료되었습니다."), + + /** + * 201 CREATED + */ + CREATE_PARENT_CHILD_SUCCESS(HttpStatus.CREATED, "온보딩 정보를 입력받아 부모자식 관계를 생성하는 데 성공했습니다."), + MATCH_PARENT_CHILD_SUCCESS(HttpStatus.CREATED, "부모자식 관계 매칭에 성공했습니다."), + ANSWER_TODAY_QUESTION_SUCCESS(HttpStatus.CREATED, "오늘의 일일문답에 답변을 완료하였습니다."), + CREATE_ALBUM_SUCCESS(HttpStatus.CREATED, "앨범의 기록 등록에 성공했습니다."), + + ; + + private final HttpStatus httpStatus; + private final String message; + + public int getHttpStatusCode() { + return httpStatus.value(); + } +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/dto/ApiResponse.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/dto/ApiResponse.java new file mode 100644 index 00000000..d86fc811 --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/dto/ApiResponse.java @@ -0,0 +1,52 @@ +package sopt.org.umbba.common.exception.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.SuccessType; + +@Getter +@JsonPropertyOrder({"status", "message", "data"}) +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + private final int status; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private T data; + + public static ApiResponse success(SuccessType successType) { + return new ApiResponse<>(successType.getHttpStatusCode(), successType.getMessage()); + } + + public static ApiResponse success(SuccessType successType, T data) { + return new ApiResponse(successType.getHttpStatusCode(), successType.getMessage(), data); + } + + public static ApiResponse error(ErrorType errorType) { + return new ApiResponse<>(errorType.getHttpStatusCode(), errorType.getMessage()); + } + + public static ApiResponse error(ErrorType errorType, String message) { + return new ApiResponse<>(errorType.getHttpStatusCode(), message); + } + + public static ApiResponse error(ErrorType errorType, String message, T data) { + return new ApiResponse<>(errorType.getHttpStatusCode(), message, data); + } + + public static ApiResponse error(ErrorType errorType, Exception e) { + return new ApiResponse<>(errorType.getHttpStatusCode(), errorType.getMessage(), e); + } + + public static ApiResponse error(ErrorType errorType, T data) { + return new ApiResponse<>(errorType.getHttpStatusCode(), errorType.getMessage(), data); + } + +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/model/CustomException.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/model/CustomException.java new file mode 100644 index 00000000..8d4e568d --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/model/CustomException.java @@ -0,0 +1,19 @@ +package sopt.org.umbba.common.exception.model; + +import lombok.Getter; +import sopt.org.umbba.common.exception.ErrorType; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorType errorType; + + public CustomException(ErrorType errorType) { + super(errorType.getMessage()); + this.errorType = errorType; + } + + public int getHttpStatus() { + return errorType.getHttpStatusCode(); + } +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/sqs/MessageType.java b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/MessageType.java new file mode 100644 index 00000000..54ffcdaa --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/MessageType.java @@ -0,0 +1,13 @@ +package sopt.org.umbba.common.sqs; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MessageType { + + public static final String MESSAGE_TYPE_HEADER = "TYPE"; + public static final String FIREBASE = "FIREBASE"; + public static final String SCHEDULE = "SCHEDULE"; + public static final String SLACK = "SLACK"; +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/sqs/MessageUtils.java b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/MessageUtils.java new file mode 100644 index 00000000..a2dcd1c7 --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/MessageUtils.java @@ -0,0 +1,17 @@ +package sopt.org.umbba.common.sqs; + +public class MessageUtils { + + public static String generate(String message, P1 param1) { + return String.format(message, param1); + } + + public static String generate(String message, P1 param1, P2 param2) { + return String.format(message, param1, param2); + } + + public static String generate(String message, P1 param1, P2 param2, P3 param3) { + return String.format(message, param1, param2, param3); + } + +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/FCMPushRequestDto.java b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/FCMPushRequestDto.java new file mode 100644 index 00000000..60c426d8 --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/FCMPushRequestDto.java @@ -0,0 +1,79 @@ +package sopt.org.umbba.common.sqs.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.common.sqs.MessageType; + +@Slf4j +@Getter +@SuperBuilder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FCMPushRequestDto extends MessageDto{ + + private String targetToken; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String title; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String body; + + + // Spring Scheduler를 이용해 Parentchild 테이블의 모든 값을 주기적으로 검사한 후 보낼 때 호출 -> 다수기기 or 주제구독 방식으로 다수의 사용자에 전송 + public static FCMPushRequestDto sendTodayQna(String section, String topic) { + + return FCMPushRequestDto.builder() + .type(MessageType.FIREBASE) + .title("📞" + section + PushMessage.TODAY_QNA.getTitle()) + .body("'" + topic + PushMessage.TODAY_QNA.getBody()) + .build(); + } + + public static FCMPushRequestDto sendTodayQna(String targetToken, String section, String topic) { + + return FCMPushRequestDto.builder() + .type(MessageType.FIREBASE) + .targetToken(targetToken) + .title("📞" + section + PushMessage.TODAY_QNA.getTitle()) + .body("'" + topic + PushMessage.TODAY_QNA.getBody()) + .build(); + } + + // QnAService or QnAController에서 특정 유저의 답변 입력 시 관계에 속한 상대 측 유저의 fcm 토큰으로 푸시 전송 + public static FCMPushRequestDto sendOpponentReply(String targetToken, String question) { + + return FCMPushRequestDto.builder() + .type(MessageType.FIREBASE) + .targetToken(targetToken) + .title(PushMessage.OPPONENT_REPLY.getTitle()) + .body("'" + question + PushMessage.OPPONENT_REPLY.getBody()) + .build(); + } + + public static FCMPushRequestDto sendOpponentRemind(String targetToken, String topic, int time) { + + if (time == 24) { + return FCMPushRequestDto.builder() + .type(MessageType.FIREBASE) + .targetToken(targetToken) + .title(PushMessage.OPPONENT_REMIND_24.getTitle()) + .body("'" + topic + PushMessage.OPPONENT_REMIND_24.getBody()) + .build(); + } else if (time == 72) { + return FCMPushRequestDto.builder() + .type(MessageType.FIREBASE) + .targetToken(targetToken) + .title(PushMessage.OPPONENT_REMIND_72.getTitle()) + .body("'" + topic + PushMessage.OPPONENT_REMIND_72.getBody()) + .build(); + } + + throw new CustomException(ErrorType.INVALID_REMIND_TIME); + } + +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/MessageDto.java b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/MessageDto.java new file mode 100644 index 00000000..84ea7620 --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/MessageDto.java @@ -0,0 +1,14 @@ +package sopt.org.umbba.common.sqs.dto; + +import lombok.*; +import lombok.experimental.SuperBuilder; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +public class MessageDto { + + protected String type; +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/PushMessage.java b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/PushMessage.java new file mode 100644 index 00000000..d768cb6a --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/PushMessage.java @@ -0,0 +1,32 @@ +package sopt.org.umbba.common.sqs.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum PushMessage { + + // 새로운 주제가 도착했을 때 + TODAY_QNA("로부터 교신이 도착했어요", + "'에 대한 질문에 답변하고 추억을 나눠보세요 ☺️(수신거부 : 설정 - 푸시알림 off)"), + + + // 주제에 대한 상대의 답변이 입력되었을 때 + OPPONENT_REPLY("📞 상대방이 교신에 응답했어요", + "'에 대한 상대의 답변을 확인해 볼까요? ☺️(수신거부 : 설정 - 푸시알림 off)"), + + + // 아직 상대방이 답변하지 않았을 때 (리마인드용) + OPPONENT_REMIND_24("📞 질문이 당신을 기다리고 있어요", + "'에 대한 질문에 답변하고 추억을 나눠보세요 ☺️(수신거부 : 설정 - 푸시알림 off)"), + + OPPONENT_REMIND_72("📞 질문이 당신을 계속 기다리고 있어요", + "'에 대한 질문에 답변하고 추억을 나눠보세요 ☺️(수신거부 : 설정 - 푸시알림 off)"); + + private String title; + private String body; + + +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/ScheduleDto.java b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/ScheduleDto.java new file mode 100644 index 00000000..a55d0d2d --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/ScheduleDto.java @@ -0,0 +1,21 @@ +package sopt.org.umbba.common.sqs.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; +import sopt.org.umbba.common.sqs.MessageType; + +@Slf4j +@Getter +@SuperBuilder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ScheduleDto extends MessageDto{ + + public static ScheduleDto of() { + return ScheduleDto.builder() + .type(MessageType.SCHEDULE) + .build(); + } +} diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/SlackDto.java b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/SlackDto.java new file mode 100644 index 00000000..fdcd3bbc --- /dev/null +++ b/umbba-common/src/main/java/sopt/org/umbba/common/sqs/dto/SlackDto.java @@ -0,0 +1,29 @@ +package sopt.org.umbba.common.sqs.dto; + +import lombok.*; +import lombok.experimental.SuperBuilder; +import sopt.org.umbba.common.sqs.MessageType; + +import javax.servlet.http.HttpServletRequest; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@SuperBuilder +public class SlackDto extends MessageDto { + + private Exception error; + private HttpServletRequest request; + private String requestMethod; + private String requestURI; + + public static SlackDto of(Exception error, String requestMethod, String requestURI) { + return SlackDto.builder() + .type(MessageType.SLACK) + .error(error) + .requestMethod(requestMethod) + .requestURI(requestURI) + .build(); + } +} diff --git a/umbba-domain/build.gradle b/umbba-domain/build.gradle new file mode 100644 index 00000000..9f7ba1b2 --- /dev/null +++ b/umbba-domain/build.gradle @@ -0,0 +1,44 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + implementation project(path: ':umbba-common') + + // JPA & Database + api "org.springframework.boot:spring-boot-starter-data-jpa" + implementation 'mysql:mysql-connector-java:8.0.32' +// runtimeOnly "mysql:mysql-connector-java" + + // JSON + implementation 'com.googlecode.json-simple:json-simple:1.1.1' + + // redis + implementation "org.springframework.boot:spring-boot-starter-data-redis" + implementation "org.springframework.session:spring-session-data-redis" + + // jackson + implementation group: "io.jsonwebtoken", name: "jjwt-jackson", version: "0.11.2" + + // querydsl + implementation "com.querydsl:querydsl-jpa" + implementation "com.querydsl:querydsl-core" + implementation "com.querydsl:querydsl-collections" + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa" // querydsl JPAAnnotationProcessor 사용 지정 + annotationProcessor "jakarta.annotation:jakarta.annotation-api" // java.lang.NoClassDefFoundError (javax.annotation.Generated) 대응 코드 + annotationProcessor "jakarta.persistence:jakarta.persistence-api" // java.lang.NoClassDefFoundError (javax.annotation.Entity) 대응 코드 + +} + +def queryDslDir = "build/querydsl/generated" + +sourceSets { + main.java.srcDirs += [queryDslDir] +} + +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(queryDslDir)) +} + +clean.doLast { + file(queryDslDir).deleteDir() +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/UmbbaDomainRoot.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/UmbbaDomainRoot.java new file mode 100644 index 00000000..0a867733 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/UmbbaDomainRoot.java @@ -0,0 +1,7 @@ +package sopt.org.umbba.domain; + +import org.springframework.context.annotation.ComponentScan; + +@ComponentScan(basePackageClasses = {UmbbaDomainRoot.class}) +public interface UmbbaDomainRoot { +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/config/jpa/JpaConfig.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/config/jpa/JpaConfig.java new file mode 100644 index 00000000..20b49642 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/config/jpa/JpaConfig.java @@ -0,0 +1,27 @@ +package sopt.org.umbba.domain.config.jpa; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import sopt.org.umbba.domain.UmbbaDomainRoot; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +@Configuration +@EntityScan(basePackageClasses = {UmbbaDomainRoot.class}) +@EnableJpaRepositories(basePackageClasses = {UmbbaDomainRoot.class}) +@EnableJpaAuditing +public class JpaConfig { + + @PersistenceContext + private EntityManager em; + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/Album.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/Album.java new file mode 100644 index 00000000..1217db48 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/Album.java @@ -0,0 +1,74 @@ +package sopt.org.umbba.domain.domain.album; + +import javax.persistence.Column; +import javax.persistence.ConstraintMode; +import javax.persistence.Entity; +import javax.persistence.ForeignKey; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Album extends AuditingTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "album_id") + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + @Column(columnDefinition = "TEXT") + private String imgUrl; + + @Column(nullable = false) + private String writer; + + @ManyToOne + @JoinColumn(name = "parentchild_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Parentchild parentchild; + + @Builder + private Album(String title, String content, String imgUrl, String writer, Parentchild parentchild) { + this.title = title; + this.content = content; + this.imgUrl = imgUrl; + this.writer = writer; + this.parentchild = parentchild; + } + + public Album(Long id, String title, String content, String imgUrl, String writer) { + this.id = id; + this.title = title; + this.content = content; + this.imgUrl = imgUrl; + this.writer = writer; + } + + public void setParentchild(Parentchild parentchild) { + this.parentchild = parentchild; + + if (!parentchild.getAlbumList().contains(this)) { + parentchild.getAlbumList().add(this); + } + } + + public void deleteParentchild() { + this.parentchild = null; + } +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/repository/AlbumRepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/repository/AlbumRepository.java new file mode 100644 index 00000000..ec073f38 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/repository/AlbumRepository.java @@ -0,0 +1,14 @@ +package sopt.org.umbba.domain.domain.album.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import sopt.org.umbba.domain.domain.album.Album; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; + +public interface AlbumRepository extends JpaRepository { + + List findAllByParentchildOrderByCreatedAtDesc(Parentchild parentchild); + +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/CloserQnA.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/CloserQnA.java new file mode 100644 index 00000000..8afb8637 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/CloserQnA.java @@ -0,0 +1,57 @@ +package sopt.org.umbba.domain.domain.closer; + +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; + +import javax.persistence.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@SQLDelete(sql = "UPDATE closer_qna SET deleted=true WHERE closer_qna_id=?") +@Where(clause = "deleted=false") +public class CloserQnA extends AuditingTimeEntity { + + @Id + @Column(name = "closer_qna_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "closer_question_id", nullable = false) + private CloserQuestion closerQuestion; + + private int parentAnswer; + + private int childAnswer; + + @Column(nullable = false) + private boolean isParentAnswer; + + @Column(nullable = false) + private boolean isChildAnswer; + + private boolean deleted = Boolean.FALSE; + + public boolean isParentAnswer() { + return isParentAnswer; + } + + public boolean isChildAnswer() { + return isChildAnswer; + } + + public void saveParentAnswer(int answer) { + this.parentAnswer = answer; + this.isParentAnswer = true; + } + + public void saveChildAnswer(int answer) { + this.childAnswer = answer; + this.isChildAnswer = true; + } +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/CloserQuestion.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/CloserQuestion.java new file mode 100644 index 00000000..9d2ea5b4 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/CloserQuestion.java @@ -0,0 +1,31 @@ +package sopt.org.umbba.domain.domain.closer; + +import lombok.*; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; + +import javax.persistence.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +public class CloserQuestion extends AuditingTimeEntity { + + @Id + @Column(name = "closer_question_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String balanceQuestion; + + @Column(nullable = false) + private String choiceAnswer1; + + @Column(nullable = false) + private String choiceAnswer2; + + @Column(columnDefinition = "TEXT") + private String imgUrl; +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/repository/CloserQnARepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/repository/CloserQnARepository.java new file mode 100644 index 00000000..570996c6 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/repository/CloserQnARepository.java @@ -0,0 +1,7 @@ +package sopt.org.umbba.domain.domain.closer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import sopt.org.umbba.domain.domain.closer.CloserQnA; + +public interface CloserQnARepository extends JpaRepository { +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/repository/CloserQuestionRepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/repository/CloserQuestionRepository.java new file mode 100644 index 00000000..0c2f1ba8 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/closer/repository/CloserQuestionRepository.java @@ -0,0 +1,39 @@ +package sopt.org.umbba.domain.domain.closer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.closer.CloserQuestion; + +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.stream.Collectors; + + +public interface CloserQuestionRepository extends JpaRepository { + + default Optional findRandomExceptIds(List ids) { + Random random = new Random(); + List allQuestions = findAll(); + + if (allQuestions.isEmpty()) { + throw new CustomException(ErrorType.NO_MORE_CLOSER_QUESTION); + } + if (ids.isEmpty()) { + int randomIndex = random.nextInt(allQuestions.size()); + return Optional.ofNullable(allQuestions.get(randomIndex)); + } + + List filteredQuestions = allQuestions.stream() + .filter(question -> !ids.contains(question.getId())) + .collect(Collectors.toList()); + + if (filteredQuestions.isEmpty()) { + return Optional.empty(); + } + + int randomIndex = random.nextInt(filteredQuestions.size()); + return Optional.of(filteredQuestions.get(randomIndex)); + } +} \ No newline at end of file diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/common/AuditingTimeEntity.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/common/AuditingTimeEntity.java new file mode 100644 index 00000000..0c085732 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/common/AuditingTimeEntity.java @@ -0,0 +1,22 @@ +package sopt.org.umbba.domain.domain.common; + +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class AuditingTimeEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java new file mode 100644 index 00000000..291611a6 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java @@ -0,0 +1,163 @@ +package sopt.org.umbba.domain.domain.parentchild; + +import lombok.*; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.album.Album; +import sopt.org.umbba.domain.domain.closer.CloserQnA; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; +import sopt.org.umbba.domain.domain.qna.OnboardingAnswer; +import sopt.org.umbba.domain.domain.qna.QnA; + +import javax.persistence.*; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@SQLDelete(sql = "UPDATE parentchild SET deleted=true WHERE parentchild_id=?") +@Where(clause = "deleted=false") +public class Parentchild extends AuditingTimeEntity { + + @Id + @Column(name = "parentchild_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToMany(fetch = FetchType.EAGER) + @JoinColumn(name = "parentchild_id") + private final List qnaList = new ArrayList<>(); + + @OneToMany(mappedBy = "parentchild", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private final List albumList = new ArrayList<>(); + + @Column(name = "question_id", nullable = false) + @ElementCollection + private List questionBlackList; + + public void addQuestionBlackList(Long questionId) { + questionBlackList.add(questionId); + } + + @Column(nullable = false) + private int count; + + public void addCount() { + this.count += 1; + log.info("Parentchild - addCount() 호출: {}", this.count); + // 미답변 일수 필드 0으로 초기화 + this.remindCnt = 0; + } + + @Column(nullable = false) + private int remindCnt; + + public void addRemindCnt() { + this.remindCnt += 1; + } + + @OneToMany + @JoinColumn(name = "parentchild_id") + private final List closerQnaList = new ArrayList<>(); + + public void addCloserQna(CloserQnA closerQnA) { + closerQnaList.add(closerQnA); + } + + @Column(nullable = false) + private int closerParentCount; + + @Column(nullable = false) + private int closerChildCount; + + public void addCloserParentCount() { + this.closerParentCount += 1; + } + + public void addCloserChildCount() { + this.closerChildCount += 1; + } + + @Column(nullable = false) + private String inviteCode; + + @Column(nullable = false) + private boolean isInvitorChild; + + @Enumerated(EnumType.STRING) + @Column(name = "answer", nullable = false) + @ElementCollection + @Builder.Default + private List childOnboardingAnswerList = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @Column(name = "answer", nullable = false) + @ElementCollection + @Builder.Default + private List parentOnboardingAnswerList = new ArrayList<>(); + + public void changeChildOnboardingAnswerList(List onboardingAnswerList) { + this.childOnboardingAnswerList = onboardingAnswerList; + } + public void changeParentOnboardingAnswerList(List onboardingAnswerList) { + this.parentOnboardingAnswerList = onboardingAnswerList; + } + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ParentchildRelation relation; + + @Column(nullable = false) + private LocalTime pushTime; // default: 오후 11시(클라이언트) + + private boolean deleted = Boolean.FALSE; + + private boolean isFirstAlbumUpload = false; + + private boolean isDeleteSampleAlbum = false; + + public void updateFirstAlbumUpload() { + this.isFirstAlbumUpload = true; + } + public void updateDeleteSampleAlbum() { + this.isDeleteSampleAlbum = true; + } + + + public void setQna(QnA qnA) { + if (qnaList.size() >= 7) { + throw new CustomException(ErrorType.ALREADY_QNA_LIST_FULL); + } + qnaList.add(qnA); + } + + public void addQna(QnA qnA) { + qnaList.add(qnA); + } + + public void addAlbum(Album album) { + this.albumList.add(album); + if (album.getParentchild() != this) { + album.setParentchild(this); + } + } + + public void deleteAlbum(Album album) { + if (this.albumList.contains(album)) { + this.albumList.remove(album); + } + } + + public boolean isOverMaxAlbumLimit() { + return getAlbumList().size() >= 15; + } + +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/ParentchildRelation.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/ParentchildRelation.java new file mode 100644 index 00000000..43191e5a --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/ParentchildRelation.java @@ -0,0 +1,94 @@ +package sopt.org.umbba.domain.domain.parentchild; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.user.User; + +import java.util.List; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum ParentchildRelation { + + DAD_SON("아빠와 아들 관계", "남자", "남자"), + DAD_DAU("아빠와 딸 관계", "남자", "여자"), + MOM_SON("엄마와 아들 관계", "여자", "남자"), + MOM_DAU("엄마와 딸 관계", "여자", "여자"); + + private final String value; + private final String parentGender; + private final String childGender; + + + public static ParentchildRelation relation(String gender, String relationInfo, boolean isInvitorChild) { + + // 내가 부모다 - 누구와 함께 하겠어? "자식" + if (!isInvitorChild) { + if (gender.equals("남자")) { // 아빠 + if (relationInfo.equals("아들")) { + return ParentchildRelation.DAD_SON; + } else if (relationInfo.equals("딸")) { + return ParentchildRelation.DAD_DAU; + } + } else if(gender.equals("여자")) { // 엄마 + if (relationInfo.equals("아들")) { + return ParentchildRelation.MOM_SON; + } else if (relationInfo.equals("딸")) { + return ParentchildRelation.DAD_DAU; + } + } + } else { // 내가 자식이다 - 누구와 함께 하겠어? "부모" + if (gender.equals("남자")) { // 아들 + if (relationInfo.equals("아빠")) { + return ParentchildRelation.DAD_SON; + } else if (relationInfo.equals("엄마")) { + return ParentchildRelation.MOM_SON; + } + } else if(gender.equals("여자")) { // 딸 + if (relationInfo.equals("아빠")) { + return ParentchildRelation.DAD_DAU; + } else if (relationInfo.equals("엄마")) { + return ParentchildRelation.MOM_DAU; + } + } + } + + throw new CustomException(ErrorType.INVALID_PARENT_CHILD_RELATION_INFO); + } + + // 아들 | 딸 | 엄마 | 아빠 구분 + public static String getUserType(ParentchildRelation relation, boolean isChild) { + if (isChild) { + return relation.childGender.equals("남자") ? "아들" : "딸"; + } else { + return relation.parentGender.equals("남자") ? "아빠" : "엄마"; + } + } + + + // 자식 유저와 부모 유저의 gender와 isMeChild 필드를 통해 ParentchildRelation을 구분하는 로직 + public static boolean validate(List parentChildUsers, ParentchildRelation relation) { + + User childUser = null; + User parentUser = null; + + for (User user : parentChildUsers) { + if (user.isMeChild()) { + childUser = user; + } else { + parentUser = user; + } + } + + if (relation.getParentGender().equals(parentUser.getGender()) + && relation.getChildGender().equals(childUser.getGender())) { + return true; + } + + return false; + } + +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/dao/ParentchildDao.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/dao/ParentchildDao.java new file mode 100644 index 00000000..e64198d0 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/dao/ParentchildDao.java @@ -0,0 +1,126 @@ +package sopt.org.umbba.domain.domain.parentchild.dao; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.jpa.JPAExpressions; +import javax.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.QUser; +import sopt.org.umbba.domain.domain.user.User; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static sopt.org.umbba.domain.domain.parentchild.QParentchild.parentchild; +import static sopt.org.umbba.domain.domain.user.QUser.user; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class ParentchildDao { + + @PersistenceContext + private EntityManager em; + + private final JPAQueryFactory queryFactory; + + public Optional findByUserId(Long userId) { + +// return Optional.ofNullable(queryFactory +// .selectFrom(parentchild) +// .leftJoin(user.parentChild, parentchild) +// .where( +// userIdEq(userId) +// ) +// .fetchOne()); + + String jpql = "SELECT pc FROM Parentchild pc " + + "JOIN User u ON u.parentChild = pc " + + "WHERE u.id = :id"; + + try { + Parentchild parentchild = em.createQuery(jpql, Parentchild.class) + .setParameter("id", userId) + .getSingleResult(); + return Optional.ofNullable(parentchild); + + } catch (NoResultException e) { + return Optional.empty(); + } finally { + em.close(); + } + } + + public Optional findMatchUserByUserId(Long userId) { + + /*QUser user = QUser.user; + QUser uc = new QUser("uc"); + + return Optional.ofNullable(queryFactory + .select(user) + .from(user) + .where(user.id.ne(userId) + .and(user.parentChild.eq( + JPAExpressions.select(uc.parentChild) + .from(uc) + .where(uc.id.eq(userId)) + ))) + .fetchOne());*/ + + String jpql = "SELECT u FROM User u " + + "JOIN User uc ON uc.parentChild = u.parentChild " + + "WHERE uc.id = :id AND uc.id != u.id"; + + try { + User user = em.createQuery(jpql, User.class) + .setParameter("id", userId) + .getSingleResult(); + return Optional.ofNullable(user); + } catch (NoResultException e) { + return Optional.empty(); + } finally { + em.close(); + } + } + + public List findFcmTokensById(Long parentchildId) { + +// return queryFactory +// .select(user.fcmToken) +// .from(user) +// .leftJoin(user.parentChild, parentchild) +// .where( +// parentchildIdEq(parentchildId) +// ) +// .fetch(); + + String jpql = "SELECT u.fcmToken FROM User u " + + "JOIN Parentchild pc ON pc.id = u.parentChild.id " + + "WHERE pc.id = :id"; + + try { + return em.createQuery(jpql, String.class) + .setParameter("id", parentchildId) + .getResultList(); + } catch (NoResultException e) { + return new ArrayList<>(); + } finally { + em.close(); + } + } + + private BooleanExpression userIdEq(Long userId) { + return userId != null ? user.id.eq(userId) : null; + } + + private BooleanExpression parentchildIdEq(Long parentchildId) { + return parentchildId != null ? parentchild.id.eq(parentchildId) : null; + } + +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/repository/ParentchildRepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/repository/ParentchildRepository.java new file mode 100644 index 00000000..a244bd32 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/repository/ParentchildRepository.java @@ -0,0 +1,24 @@ +package sopt.org.umbba.domain.domain.parentchild.repository; + +import org.springframework.data.repository.Repository; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; + +import java.util.List; +import java.util.Optional; + +public interface ParentchildRepository extends Repository { + + // CREATE + void save(Parentchild parentchild); + + // READ + Optional findById(Long id); + Optional findByInviteCode(String inviteCode); + + List findAll(); + + // UPDATE + + // DELETE + void deleteById(Long id); +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/OnboardingAnswer.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/OnboardingAnswer.java new file mode 100644 index 00000000..c817bf19 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/OnboardingAnswer.java @@ -0,0 +1,28 @@ +package sopt.org.umbba.domain.domain.qna; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum OnboardingAnswer { + + YES("응"), + NO("아니"), + SKIP("애매해"); + + private final String value; + + public static OnboardingAnswer of(String value) { + for (OnboardingAnswer answer : OnboardingAnswer.values()) { + if (answer.getValue().equals(value)) { + return answer; + } + } + throw new CustomException(ErrorType.INVALID_ONBOARDING_ANSWER); + } + +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QnA.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QnA.java new file mode 100644 index 00000000..ff6892b4 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QnA.java @@ -0,0 +1,61 @@ +package sopt.org.umbba.domain.domain.qna; + +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; + +import javax.persistence.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@SQLDelete(sql = "UPDATE qna SET deleted=true WHERE qna_id=?") +@Where(clause = "deleted=false") +public class QnA extends AuditingTimeEntity { + + @Id + @Column(name = "qna_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "question_id", nullable = false) + private Question question; + + private String parentAnswer; + + private String childAnswer; + + @Column(nullable = false) + private boolean isParentAnswer; + + @Column(nullable = false) + private boolean isChildAnswer; + + private boolean deleted = Boolean.FALSE; + + public boolean isParentAnswer() { + return isParentAnswer; + } + + public boolean isChildAnswer() { + return isChildAnswer; + } + + public void saveParentAnswer(String answer) { + this.parentAnswer = answer; + this.isParentAnswer = true; + } + + public void saveChildAnswer(String answer) { + this.childAnswer = answer; + this.isChildAnswer = true; + } + + public void changeQuestion(Question question) { + this.question = question; + } +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/Question.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/Question.java new file mode 100644 index 00000000..703645df --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/Question.java @@ -0,0 +1,36 @@ +package sopt.org.umbba.domain.domain.qna; + +import lombok.*; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; + +import javax.persistence.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +public class Question extends AuditingTimeEntity { + + @Id + @Column(name = "question_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String parentQuestion; + + @Column(nullable = false) + private String childQuestion; + + @Column(nullable = false) + private String topic; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private QuestionSection section; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private QuestionType type; +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QuestionSection.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QuestionSection.java new file mode 100644 index 00000000..38ee957d --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QuestionSection.java @@ -0,0 +1,21 @@ +package sopt.org.umbba.domain.domain.qna; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum QuestionSection { + YOUNG(1L, "어린시절", 1), + SCHOOL(2L, "학창시절", 1), + GOLDEN(3L, "청춘시절", 2), + COUPLE(4L, "연애시절", 1), + MARRIAGE(5L, "우리가 만나고", 1), + MARRIAGE2(5L, "우리가 만나고", 1); // 전연령 - 우리가 만나고 + ; + + private final Long sectionId; //findBySectionId 하면 안됨 + private final String value; + private final int questionCount; +} \ No newline at end of file diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QuestionType.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QuestionType.java new file mode 100644 index 00000000..461406c6 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/QuestionType.java @@ -0,0 +1,23 @@ +package sopt.org.umbba.domain.domain.qna; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum QuestionType { + + + TYPE1(1L, "타입 1"), + TYPE2(2L, "타입 2"), + TYPE3(3L, "타입 3"), + TYPE4(4L, "타입 4"), + TYPE5(5L, "타입 5"), + MAIN(10L, "메인 타입"), + FIX(11L, "고정된 질문"), + YET(12L, "아직 사용하지 않는 질문"); + + private final Long typeId; + private final String description; +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/dao/QnADao.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/dao/QnADao.java new file mode 100644 index 00000000..c82dd0aa --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/dao/QnADao.java @@ -0,0 +1,70 @@ +package sopt.org.umbba.domain.domain.qna.dao; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import sopt.org.umbba.domain.domain.qna.QnA; + +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.PersistenceContext; +import javax.persistence.TypedQuery; +import java.util.List; +import java.util.Optional; + +import static sopt.org.umbba.domain.domain.parentchild.QParentchild.parentchild; +import static sopt.org.umbba.domain.domain.user.QUser.user; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class QnADao { + + @PersistenceContext + private EntityManager em; + + private final JPAQueryFactory queryFactory; + + + // READ + + // 유저 아이디로 QnA 리스트 조회하기 + public Optional> findQnASByUserId(Long userId) { +// return queryFactory +// .select(parentchild.qnaList) +// .from(parentchild) +// .leftJoin(user.parentChild, parentchild) +// .where(userIdEq(userId)) +// .fetchOne(); + + log.info("jpql 실행 전"); + String jpql = "SELECT q FROM Parentchild pc " + + "JOIN pc.qnaList q " + + "LEFT JOIN User u ON u.parentChild.id = pc.id " + + "WHERE u.id = :id"; + + try { + TypedQuery query = em.createQuery(jpql, QnA.class); + + log.info("query 실행 성공: {}", query); + List qnaList = query + .setParameter("id", userId) + .getResultList(); + log.info("query 실행 결과: {}", qnaList.toString()); + + return Optional.of(qnaList); + } catch (NoResultException e) { + + return Optional.empty(); + } finally { + em.close(); + } + } + + private BooleanExpression userIdEq(Long userId) { + return userId != null ? user.id.eq(userId) : null; + } + +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QnARepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QnARepository.java new file mode 100644 index 00000000..d10413b8 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QnARepository.java @@ -0,0 +1,15 @@ +package sopt.org.umbba.domain.domain.qna.repository; + +import org.springframework.data.repository.Repository; +import sopt.org.umbba.domain.domain.qna.QnA; + +import java.util.Optional; + +public interface QnARepository extends Repository { + + void save(QnA qnA); + + Optional findQnAById(Long id); + + void deleteById(Long id); +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QuestionRepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QuestionRepository.java new file mode 100644 index 00000000..07afe0b8 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QuestionRepository.java @@ -0,0 +1,47 @@ +package sopt.org.umbba.domain.domain.qna.repository; + +import org.springframework.data.repository.Repository; +import sopt.org.umbba.domain.domain.qna.Question; +import sopt.org.umbba.domain.domain.qna.QuestionSection; +import sopt.org.umbba.domain.domain.qna.QuestionType; + +import java.util.*; +import java.util.stream.Collectors; + + +public interface QuestionRepository extends Repository { + + Optional findById(Long id); + + List findBySectionAndType(QuestionSection section, QuestionType type); + + List findByType(QuestionType type); + + List findByTypeInAndIdNotIn(List types, List doneQuestionIds); + + default List findBySectionAndTypeRandom(QuestionSection section, QuestionType type, int size) { + List matchingQuestions = findBySectionAndType(section, type); + List selectedQuestions = new ArrayList<>(); + + int totalMatchingQuestions = matchingQuestions.size(); + int numQuestionsToSelect = Math.min(totalMatchingQuestions, size); + Random random = new Random(); + + for (int i = 0; i < numQuestionsToSelect; i++) { + int randomIndex = random.nextInt(totalMatchingQuestions); + Question selectedQuestion = matchingQuestions.get(randomIndex); + selectedQuestions.add(selectedQuestion); + matchingQuestions.remove(randomIndex); + totalMatchingQuestions--; + } + + return selectedQuestions; + } + + default List findByTypeOrderBySectionId(QuestionType type) { + return findByType(type) + .stream() + .sorted(Comparator.comparing(question -> question.getSection().getSectionId())) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/redis/RefreshToken.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/redis/RefreshToken.java new file mode 100644 index 00000000..5d1f35c7 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/redis/RefreshToken.java @@ -0,0 +1,29 @@ +package sopt.org.umbba.domain.domain.redis; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import javax.persistence.Id; +import java.util.concurrent.TimeUnit; + +@Getter +@RedisHash("refreshToken") +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RefreshToken { + + @Id + @JsonIgnore + private Long id; + + private String refreshToken; + + @TimeToLive(unit = TimeUnit.SECONDS) + private Integer expiration; +} \ No newline at end of file diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/SocialPlatform.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/SocialPlatform.java new file mode 100644 index 00000000..f1503589 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/SocialPlatform.java @@ -0,0 +1,27 @@ +package sopt.org.umbba.domain.domain.user; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum SocialPlatform { + KAKAO("카카오"), + APPLE("애플"), + WITHDRAW("탈퇴한 유저") + ; + + private final String value; + + public static SocialPlatform of(String value) { + for (SocialPlatform platform : SocialPlatform.values()) { + if (platform.toString().equals(value)) { + return platform; + } + } + throw new CustomException(ErrorType.INVALID_SOCIALPLATFORM); + } +} \ No newline at end of file diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/User.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/User.java new file mode 100644 index 00000000..f44a9001 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/User.java @@ -0,0 +1,172 @@ +package sopt.org.umbba.domain.domain.user; + +import java.time.LocalDateTime; +import java.util.List; + +import javax.persistence.Column; +import javax.persistence.ConstraintMode; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.ForeignKey; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; + +@Slf4j +@Entity +@Table(name = "`User`") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@SQLDelete(sql = "UPDATE user SET deleted=true WHERE user_id=?") +@Where(clause = "deleted=false") +public class User extends AuditingTimeEntity { + + @Id + @Column(name = "user_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // @Column(nullable = false) // 사실 온보딩 단계에서 입력되기 때문에 nullable = true로 가져가야함 + private String username; + + // @Column(nullable = false) + private String gender; + + // @Column(nullable = false) + private Integer bornYear; + + @ManyToOne + @JoinColumn(name = "parentchild_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) // 외래 키 제약조건 제거 + private Parentchild parentChild; + + public void updateParentchild(Parentchild parentchild) { + this.parentChild = parentchild; + } + + @Column(nullable = false) + private boolean isMeChild; + + public void updateIsMeChild(boolean isMeChild) { + this.isMeChild = isMeChild; + } + + @Column(nullable = false) + private boolean isMatchFinish; + + public void updateIsMatchFinish(boolean isMatchFinish) { + this.isMatchFinish = isMatchFinish; + } + + private String refreshToken; + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + // ** FCM 푸시 알림 관련 ** +// @Column(nullable = false) + private String fcmToken; // registration+token + + public void updateFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } + + // ** 소셜 로그인 관련 ** + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SocialPlatform socialPlatform; + + @Column(nullable = false) // 이걸 PK로 가져갈지 고민 + private String socialId; + + private String socialNickname; + + private String socialProfileImage; + + private String socialAccessToken; + +// private String socialRefreshToken; + + private boolean deleted = Boolean.FALSE; + + private boolean isFirstEntry = Boolean.TRUE; + + private boolean isEndingDone = Boolean.FALSE; + + private LocalDateTime lastRerollChange; + + public void updateLastRerollChange() { + this.lastRerollChange = LocalDateTime.now(); + } + + // 로그인 새롭게 할 때마다 해당 필드들 업데이트 + public void updateSocialInfo(String socialNickname, String socialProfileImage, String socialAccessToken/*, String socialRefreshToken*/) { + this.socialNickname = socialNickname; + this.socialProfileImage = socialProfileImage; + this.socialAccessToken = socialAccessToken; +// this.socialRefreshToken = socialRefreshToken; + } + + public void updateOnboardingInfo(String name, String gender, Integer bornYear) { + this.username = name; + this.gender = gender; + this.bornYear = bornYear; + } + + public void updateIsFirstEntry() { + this.isFirstEntry = false; + } + + public void updateIsEndingDone() { this.isEndingDone = true; } + + public void deleteSocialInfo() { + this.socialPlatform = SocialPlatform.WITHDRAW; + this.socialNickname = null; + this.socialProfileImage = null; + this.socialAccessToken = null; + } + + public User(SocialPlatform socialPlatform, String socialId) { + this.socialPlatform = socialPlatform; + this.socialId = socialId; + } + + public boolean validateParentchild(List parentChildUsers) { + + // 부모자식 관계에 대한 예외처리 + if (parentChildUsers.isEmpty()) { + return false; +// throw new CustomException(ErrorType.NOT_EXIST_PARENT_CHILD_USER); + } + + if (parentChildUsers.size() == 1) { + return false; +// throw new CustomException(ErrorType.NOT_MATCH_PARENT_CHILD_RELATION); + } else if (parentChildUsers.size() != 2) { + return false; +// throw new CustomException(ErrorType.INVALID_PARENT_CHILD_RELATION); + } + + log.info("성립된 부모자식: {} X {}, 관계: {}", parentChildUsers.get(0).getUsername(), parentChildUsers.get(1).getUsername(), this.parentChild.getRelation()); + + return true; + } +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/repository/UserRepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..c4935cc2 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/user/repository/UserRepository.java @@ -0,0 +1,34 @@ +package sopt.org.umbba.domain.domain.user.repository; + +import org.springframework.data.repository.Repository; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.SocialPlatform; +import sopt.org.umbba.domain.domain.user.User; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends Repository { + + // CREATE + void save(User user); + + // READ + Optional findById(Long id); + boolean existsBySocialPlatformAndSocialId(SocialPlatform socialPlatform, String socialId); + List findBySocialPlatformAndSocialId(SocialPlatform socialPlatform, String socialId); + Optional findByFcmToken(String fcmToken); + + // DELETE + void deleteById(Long id); + + /*@Query(value = "select user " + + "from User user " + + "where user.parentChild.id = :parentchild_id")*/ + List findUserByParentChild(Parentchild parentchild); + + + // UPDATE + + // DELETE +} diff --git a/umbba-external/build.gradle b/umbba-external/build.gradle new file mode 100644 index 00000000..07adf909 --- /dev/null +++ b/umbba-external/build.gradle @@ -0,0 +1,19 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + implementation project(":umbba-common") + + // Social Login + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:3.1.7' + + // for JsonIgnore + implementation group: "io.jsonwebtoken", name: "jjwt-jackson", version: "0.11.2" + + // AWS sdk + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") + + // spring webflux +// implementation "org.springframework.boot:spring-boot-starter-webflux" +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/UmbbaExternalRoot.java b/umbba-external/src/main/java/sopt/org/umbba/external/UmbbaExternalRoot.java new file mode 100644 index 00000000..09e80219 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/UmbbaExternalRoot.java @@ -0,0 +1,7 @@ +package sopt.org.umbba.external; + +import org.springframework.context.annotation.ComponentScan; + +@ComponentScan(basePackageClasses = {UmbbaExternalRoot.class}) +public interface UmbbaExternalRoot { +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/AppleApiClient.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/AppleApiClient.java new file mode 100644 index 00000000..50897a71 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/AppleApiClient.java @@ -0,0 +1,12 @@ +package sopt.org.umbba.external.client.auth.apple; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import sopt.org.umbba.external.client.auth.apple.response.ApplePublicKeys; + +@FeignClient(name = "apple-public-verify-client", url = "https://appleid.apple.com/auth") +public interface AppleApiClient { + + @GetMapping("/keys") + ApplePublicKeys getApplePublicKeys(); +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/response/ApplePublicKey.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/response/ApplePublicKey.java new file mode 100644 index 00000000..402f9081 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/response/ApplePublicKey.java @@ -0,0 +1,20 @@ +package sopt.org.umbba.external.client.auth.apple.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class ApplePublicKey { + + private String kty; + private String kid; + private String use; + private String alg; + private String n; + private String e; +} + diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/response/ApplePublicKeys.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/response/ApplePublicKeys.java new file mode 100644 index 00000000..a3938682 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/apple/response/ApplePublicKeys.java @@ -0,0 +1,27 @@ +package sopt.org.umbba.external.client.auth.apple.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.common.exception.ErrorType; + +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class ApplePublicKeys { + + private List keys; + + public ApplePublicKey getMatchesKey(String alg, String kid) { + return this.keys + .stream() + .filter(k -> k.getAlg().equals(alg) && k.getKid().equals(kid)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorType.INVALID_APPLE_PUBLIC_KEY)); + } + +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/KakaoApiClient.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/KakaoApiClient.java new file mode 100644 index 00000000..6d7d9769 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/KakaoApiClient.java @@ -0,0 +1,15 @@ +package sopt.org.umbba.external.client.auth.kakao; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import sopt.org.umbba.external.client.auth.kakao.response.KakaoUserResponse; + +@FeignClient(name = "kakaoApiClient", url = "https://kapi.kakao.com") +public interface KakaoApiClient { + + //Access 토큰을 활용해서 실제 유저 정보를 가져오는 역할 + @GetMapping(value = "/v2/user/me") + KakaoUserResponse getUserInformation(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken); +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/KakaoAuthApiClient.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/KakaoAuthApiClient.java new file mode 100644 index 00000000..a39f765d --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/KakaoAuthApiClient.java @@ -0,0 +1,20 @@ +package sopt.org.umbba.external.client.auth.kakao; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import sopt.org.umbba.external.client.auth.kakao.response.KakaoAccessTokenResponse; + +@FeignClient(name = "kakaoAuthApiClient", url = "https://kauth.kakao.com") +public interface KakaoAuthApiClient { + + //Authorization Code를 활용해서 Access Token + Refresh Token을 받아오는 역할 + @PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + KakaoAccessTokenResponse getOAuth2AccessToken( + @RequestParam("grant_type") String grantType, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("code") String code + ); +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoAccessTokenResponse.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoAccessTokenResponse.java new file mode 100644 index 00000000..2305ae7a --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoAccessTokenResponse.java @@ -0,0 +1,20 @@ +package sopt.org.umbba.external.client.auth.kakao.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.*; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoAccessTokenResponse { + + private String accessToken; + private String refreshToken; + + public static KakaoAccessTokenResponse of(String accessToken, String refreshToken) { + return new KakaoAccessTokenResponse(accessToken, refreshToken); + } +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoAccount.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoAccount.java new file mode 100644 index 00000000..0a673876 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoAccount.java @@ -0,0 +1,15 @@ +package sopt.org.umbba.external.client.auth.kakao.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.*; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoAccount { + + private KakaoUserProfile profile; +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoUserProfile.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoUserProfile.java new file mode 100644 index 00000000..5303e0ea --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoUserProfile.java @@ -0,0 +1,17 @@ +package sopt.org.umbba.external.client.auth.kakao.response; + +import lombok.*; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoUserProfile { + + private String nickname; + private String profileImageUrl; +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoUserResponse.java b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoUserResponse.java new file mode 100644 index 00000000..858ca383 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/client/auth/kakao/response/KakaoUserResponse.java @@ -0,0 +1,18 @@ +package sopt.org.umbba.external.client.auth.kakao.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.*; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoUserResponse { + + //받아올 땐 Long이지만, String으로 바꿔서 사용하기 + private Long id; + + private KakaoAccount kakaoAccount; +} \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/PreSignedUrlDto.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/PreSignedUrlDto.java new file mode 100644 index 00000000..0f5554fe --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/PreSignedUrlDto.java @@ -0,0 +1,23 @@ +package sopt.org.umbba.external.s3; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class PreSignedUrlDto { + + private String fileName; + private String url; + + public static PreSignedUrlDto of(String fileName, String url) { + return PreSignedUrlDto.builder() + .fileName(fileName) + .url(url) + .build(); + } +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3BucketPrefix.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3BucketPrefix.java new file mode 100644 index 00000000..5c1b3176 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3BucketPrefix.java @@ -0,0 +1,25 @@ +package sopt.org.umbba.external.s3; + +import static sopt.org.umbba.common.exception.ErrorType.*; + +import java.util.Arrays; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.common.exception.model.CustomException; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum S3BucketPrefix { + ALBUM_PREFIX("album/"); + + private final String value; + + public static S3BucketPrefix of(String value) { + return Arrays.stream(S3BucketPrefix.values()) + .filter(prefix -> value.equals(prefix.value)) + .findFirst() + .orElseThrow(() -> new CustomException(INVALID_BUCKET_PREFIX)); + } +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3Service.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3Service.java new file mode 100644 index 00000000..6a21a903 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3Service.java @@ -0,0 +1,115 @@ +package sopt.org.umbba.external.s3; + +import static sopt.org.umbba.common.exception.ErrorType.*; + +import java.net.URL; +import java.time.Duration; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import sopt.org.umbba.common.exception.model.CustomException; + +@Slf4j +@Component +public class S3Service { + + private static final Long PRE_SIGNED_URL_EXPIRE_MINUTE = 1L; // 만료시간 1분 + private static final String IMAGE_EXTENSION = ".jpg"; + private static final String AWS_DOMAIN = "amazonaws.com/"; + + private final String bucketName; + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + public S3Service(@Value("${cloud.aws.s3.bucketImg}") final String bucketName, final S3Client s3Client, final S3Presigner s3Presigner) { + this.bucketName = bucketName; + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + } + + // 이미지 저장을 위한 PreSigned Url 발급 + public PreSignedUrlDto getPreSignedUrl(final S3BucketPrefix prefix) { + final String fileName = generateImageFileName(); // UUID 문자열 + final String key = prefix.getValue() + fileName; + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(key).build(); + + PutObjectPresignRequest preSignedUrlRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(PRE_SIGNED_URL_EXPIRE_MINUTE)) + .putObjectRequest(request).build(); + + String url = s3Presigner.presignPutObject(preSignedUrlRequest).url().toString(); + return PreSignedUrlDto.of(fileName, url); + } catch (RuntimeException e) { + throw new CustomException(FAIL_TO_GET_IMAGE_PRE_SIGNED_URL); + } + } + + private String generateImageFileName() { + return UUID.randomUUID() + IMAGE_EXTENSION; + } + + // S3 버킷으로부터 이미지 삭제 + public void deleteS3Image(String url) { + String key = getKeyByUrl(url); + try { + s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> + builder.bucket(bucketName) + .key(key).build()); + } catch (RuntimeException e) { + throw new CustomException(FAIL_TO_DELETE_IMAGE); + } + } + + // 파일명으로부터 S3 Bucket URL 조회 + public String getS3ImgUrl(String prefix, String fileName) { + + String imageKey = prefix + fileName; + + try { + GetUrlRequest request = GetUrlRequest.builder() + .bucket(bucketName) + .key(imageKey) + .build(); + + URL imageUrl = s3Client.utilities().getUrl(request); + + String urlWithKey = "https://" + bucketName + ".s3.ap-northeast-2.amazonaws.com/" + imageKey; + if (urlWithKey.equals(imageUrl.toString())) { + log.info("S3에 저장된 이미지 Url: {}", imageUrl); + return imageUrl.toString(); + } + throw new CustomException(S3_BUCKET_GET_IMAGE_ERROR); + } catch (S3Exception e) { + throw new CustomException(S3_BUCKET_GET_IMAGE_ERROR); + } + } + + private String getKeyByUrl(String imgUrl) { + + int index = imgUrl.indexOf(AWS_DOMAIN); + String imageKey = ""; + if (index != -1) { + imageKey = imgUrl.substring(index + AWS_DOMAIN.length()); + log.info("imageKey substring으로 가져옴: {}", imageKey); + } else { + log.error("imageKey substring으로 가져오기 실패"); + } + + return imageKey; + } +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/config/S3Config.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/config/S3Config.java new file mode 100644 index 00000000..3131612c --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/config/S3Config.java @@ -0,0 +1,57 @@ +package sopt.org.umbba.external.s3.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + private static String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; + private static String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; + + private final String accessKey; + private final String secretKey; + private final String regionString; + + public S3Config(@Value("${cloud.aws.credentials.accessKey}") final String accessKey, + @Value("${cloud.aws.credentials.secretKey}") final String secretKey, + @Value("${cloud.aws.region.static}") final String regionString) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.regionString = regionString; + } + + @Bean + public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() { + System.setProperty(AWS_ACCESS_KEY_ID, accessKey); + System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey); + return SystemPropertyCredentialsProvider.create(); + } + + @Bean + public Region getRegion() { + return Region.of(regionString); + } + + @Bean + public S3Client getS3Client() { + return S3Client.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } + + @Bean + public S3Presigner getS3PreSigner() { + return S3Presigner.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } +} \ No newline at end of file diff --git a/umbba-notification/build.gradle b/umbba-notification/build.gradle new file mode 100644 index 00000000..533d885c --- /dev/null +++ b/umbba-notification/build.gradle @@ -0,0 +1,23 @@ +jar { enabled = false } + +dependencies { + implementation project(":umbba-domain") + implementation project(":umbba-common") + implementation project(":umbba-external") + + implementation "org.springframework.boot:spring-boot-starter-web" + + // SQS + implementation "org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE" + implementation "org.springframework.cloud:spring-cloud-aws-messaging:2.2.6.RELEASE" + + // FCM + implementation "com.google.firebase:firebase-admin:9.1.1" + + // Slack Webhook + implementation 'com.slack.api:slack-api-client:1.28.0' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.squareup.okhttp3:okhttp:4.10.0' + implementation 'com.slack.api:slack-app-backend:1.28.0' + implementation 'com.slack.api:slack-api-model:1.28.0' +} \ No newline at end of file diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/NotificationApplication.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/NotificationApplication.java new file mode 100644 index 00000000..aa42b4e9 --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/NotificationApplication.java @@ -0,0 +1,21 @@ +package sopt.org.umbba.notification; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import sopt.org.umbba.common.UmbbaCommonRoot; +import sopt.org.umbba.domain.UmbbaDomainRoot; +import sopt.org.umbba.external.UmbbaExternalRoot; + +@SpringBootApplication(scanBasePackageClasses = { + UmbbaCommonRoot.class, + UmbbaDomainRoot.class, + UmbbaExternalRoot.class, + NotificationApplication.class +})public class NotificationApplication { + + public static void main(String[] args) { + SpringApplication.run(NotificationApplication.class, args); + } + +} \ No newline at end of file diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/config/FCMConfig.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/FCMConfig.java new file mode 100644 index 00000000..f81e6b3b --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/FCMConfig.java @@ -0,0 +1,104 @@ +package sopt.org.umbba.notification.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +@Slf4j +@Configuration +public class FCMConfig { + + @Value("${fcm.key.path}") + private String SERVICE_ACCOUNT_JSON; + + // SDK 초기화: ADC를 사용하여 사용자 인증 정보 제공 -> 보내기 요청 승인 후에 사용 가능 + @PostConstruct + public void init() { + try { + ClassPathResource resource = new ClassPathResource(SERVICE_ACCOUNT_JSON); + InputStream serviceAccount = resource.getInputStream(); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + FirebaseApp.initializeApp(options); + } catch (IOException e) { + log.error("파이어베이스 서버와의 연결에 실패했습니다."); + throw new CustomException(ErrorType.FIREBASE_CONNECTION_ERROR); + } + } + + // 여러 개의 파이어베이스 앱을 사용하는 경우 + @Bean + FirebaseMessaging firebaseMessaging() throws IOException { + + ClassPathResource resource = new ClassPathResource(SERVICE_ACCOUNT_JSON); + InputStream refreshToken = resource.getInputStream(); + + FirebaseApp firebaseApp = null; + List firebaseAppList = FirebaseApp.getApps(); + + if (!firebaseAppList.isEmpty() && firebaseAppList != null) { + for (FirebaseApp app : firebaseAppList) { + if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) { + firebaseApp = app; + } + } + } else { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(refreshToken)) + .build(); + + firebaseApp = FirebaseApp.initializeApp(options); + } + + return FirebaseMessaging.getInstance(firebaseApp); + + + } + + // TODO 플랫폼마다 별도의 설정이 필요한 경우 사용 + + // Android + public AndroidConfig TokenAndroidConfig(FCMPushRequestDto request) { + return AndroidConfig.builder() +// .setCollapseKey(request.getCollapseKey()) + .setNotification(AndroidNotification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .build()) + .build(); + } + + // Apple + public ApnsConfig TokenApnsConfig(FCMPushRequestDto request) { + return ApnsConfig.builder() + .setAps(Aps.builder() + .setAlert( + ApsAlert.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) +// .setLaunchImage(request.getImgUrl()) + .build() + ) +// .setCategory(request.getCollapseKey()) + .setSound("default") + .build()) + .build(); + } +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/config/GracefulShutdown.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/GracefulShutdown.java new file mode 100644 index 00000000..a3689ce7 --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/GracefulShutdown.java @@ -0,0 +1,68 @@ +package sopt.org.umbba.notification.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; +import org.springframework.cloud.aws.messaging.listener.SimpleMessageListenerContainer; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@Component +public class GracefulShutdown implements SmartLifecycle, BeanFactoryAware { + private final Map messageListenerContainers; + + private boolean isRunning; + private DefaultSingletonBeanRegistry beanFactory; + + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = (DefaultSingletonBeanRegistry) beanFactory; + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public void stop(Runnable callback) { + stop(); + callback.run(); + } + + @Override + public void start() { + this.isRunning = true; + } + + @Override + public void stop() { + stopSqsListeners(); + this.isRunning = false; + log.info("[GracefulShutdown 완료]"); + } + + private void stopSqsListeners() { + this.messageListenerContainers.keySet().forEach(beanName -> beanFactory.destroySingleton(beanName)); + } + + + @Override + public boolean isRunning() { + return this.isRunning; + } + + @Override + public int getPhase() { + return Integer.MAX_VALUE; // 명시적으로 PHASE 설정 + } + +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/config/ScheduleConfig.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/ScheduleConfig.java new file mode 100644 index 00000000..16f74083 --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/ScheduleConfig.java @@ -0,0 +1,50 @@ +package sopt.org.umbba.notification.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import sopt.org.umbba.notification.service.fcm.FCMService; + +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 특정 시간대에 알림을 보내주기 위해 Spring이 제공하는 TaskScheduler를 빈으로 등록 + */ +@Configuration +@EnableScheduling +public class ScheduleConfig { + + private static final int POOL_SIZE = 10; + private static ThreadPoolTaskScheduler scheduler; + + + @Bean + public TaskScheduler scheduler() { + scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(POOL_SIZE); + scheduler.setThreadNamePrefix("현재 쓰레드 풀-"); + scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + scheduler.initialize(); + return scheduler; + } + + // 스케줄러 중지 후 재시작 (초기화) + public static void resetScheduler() { + scheduler.shutdown(); + FCMService.clearScheduledTasks(); + scheduler.setPoolSize(POOL_SIZE); + scheduler.setThreadNamePrefix("현재 쓰레드 풀-"); + scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + scheduler.initialize(); + } + + + + // 단일 스레드로 예약된 작업을 처리하고자 할 때 사용 + /*@Bean + public TaskScheduler scheduler() { + return new ConcurrentTaskScheduler(); + }*/ +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/config/SqsConfig.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/SqsConfig.java new file mode 100644 index 00000000..a1f6f010 --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/SqsConfig.java @@ -0,0 +1,34 @@ +package sopt.org.umbba.notification.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.sqs.AmazonSQSAsync; +import com.amazonaws.services.sqs.AmazonSQSAsyncClient; +import com.amazonaws.services.sqs.AmazonSQSAsyncClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class SqsConfig { + + @Value("${cloud.aws.credentials.accessKey}") + private String AWS_ACCESS_KEY; + + @Value("${cloud.aws.credentials.secretKey}") + private String AWS_SECRET_KEY; + + @Value("${cloud.aws.region.static}") + private String AWS_REGION; + + @Primary + @Bean + public AmazonSQSAsync amazonSQSAsync() { + BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(AWS_ACCESS_KEY, AWS_SECRET_KEY); + return AmazonSQSAsyncClientBuilder.standard() + .withRegion(AWS_REGION) + .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials)) + .build(); + } +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/config/sqs/consumer/SqsConsumer.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/sqs/consumer/SqsConsumer.java new file mode 100644 index 00000000..59d12f25 --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/config/sqs/consumer/SqsConsumer.java @@ -0,0 +1,74 @@ +package sopt.org.umbba.notification.config.sqs.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.aws.messaging.listener.Acknowledgment; +import org.springframework.cloud.aws.messaging.listener.SqsMessageDeletionPolicy; +import org.springframework.cloud.aws.messaging.listener.annotation.SqsListener; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; +import sopt.org.umbba.common.sqs.MessageType; +import sopt.org.umbba.common.sqs.MessageUtils; +import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto; +import sopt.org.umbba.common.sqs.dto.SlackDto; +import sopt.org.umbba.notification.config.ScheduleConfig; +import sopt.org.umbba.notification.service.fcm.FCMService; +import sopt.org.umbba.notification.service.scheduler.FCMScheduler; +import sopt.org.umbba.notification.service.slack.SlackApi; + +import java.util.Map; +import java.util.Objects; + +/** + * 큐 대기열에 있는 메시지 목록을 조회하여 받아오는(pull) 역할 + * + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SqsConsumer { + + private final ObjectMapper objectMapper; + private final FCMService fcmService; + private final FCMScheduler fcmScheduler; + private final SlackApi slackApi; + private static final String SQS_CONSUME_LOG_MESSAGE = + "====> [SQS Queue Response]\n" + "info: %s\n" + "header: %s\n"; + + + // SQS로부터 메시지를 받는 Listener | 메시지를 받은 이후의 삭제 정책을 NEVER로 지정 + // -> 절대 삭제 요청을 보내지 않고, ack 메서드를 호출할 때 삭제 요청을 보냄 + @SqsListener(value = "${cloud.aws.sqs.notification.name}", deletionPolicy = SqsMessageDeletionPolicy.NEVER) + public void consume(@Payload String payload, @Headers Map headers, Acknowledgment ack) { + try { + + switch (headers.get(MessageType.MESSAGE_TYPE_HEADER)) { + + case MessageType.FIREBASE: + FCMPushRequestDto request = objectMapper.readValue(payload, FCMPushRequestDto.class); + fcmService.pushAlarm(request); // TODO userId 를 넘겨주는 방식 대신 어떻게 유저 식별할지? + log.info(MessageUtils.generate(SQS_CONSUME_LOG_MESSAGE, payload, headers)); + break; + + case MessageType.SCHEDULE: + ScheduleConfig.resetScheduler(); + fcmScheduler.pushTodayQna(); + log.info(MessageUtils.generate(SQS_CONSUME_LOG_MESSAGE, payload, headers)); + break; + + case MessageType.SLACK: + SlackDto slackDto = objectMapper.readValue(payload, SlackDto.class); + slackApi.sendAlert(slackDto.getError(), slackDto.getRequestMethod(), slackDto.getRequestURI()); + log.info(MessageUtils.generate(SQS_CONSUME_LOG_MESSAGE, "Slack 500 Error info", "Slack 500 Error header")); + break; + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + + ack.acknowledge(); + } + +} \ No newline at end of file diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMController.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMController.java new file mode 100644 index 00000000..1fb4345a --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMController.java @@ -0,0 +1,65 @@ +package sopt.org.umbba.notification.service.fcm; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import sopt.org.umbba.common.exception.SuccessType; +import sopt.org.umbba.common.exception.dto.ApiResponse; +import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto; +import sopt.org.umbba.notification.config.ScheduleConfig; +import sopt.org.umbba.notification.service.scheduler.FCMScheduler; + +import java.io.IOException; +import java.security.Principal; + +@RestController +@RequestMapping("/alarm") +@RequiredArgsConstructor +public class FCMController { + + private final FCMService fcmService; + private final FCMScheduler fcmScheduler; + + + /** + * 새로운 질문이 도착했음을 알리는 푸시 알림 활성화 API + * 실제로는 초대 받는측의 온보딩이 완료되었을 때 호출됨 + */ + @PostMapping("/qna") + @ResponseStatus(HttpStatus.OK) + public ApiResponse sendTopicScheduledTest() { + ScheduleConfig.resetScheduler(); + return ApiResponse.success(SuccessType.PUSH_ALARM_PERIODIC_SUCCESS, fcmScheduler.pushTodayQna()); + } + + /** + * 장난용 푸시 알림 활성화 API + */ +// @PostMapping("/drink") +// @ResponseStatus(HttpStatus.OK) +// public ApiResponse drinkAlarm() { +// return ApiResponse.success(SuccessType.PUSH_ALARM_PERIODIC_SUCCESS, fcmScheduler.drink()); +// } + + + /** + * 헤더와 바디를 직접 만들어 알림을 전송하는 테스트용 API (상대 답변 알람 전송에 사용) + */ + @PostMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse sendNotificationByToken(@RequestBody FCMPushRequestDto request, Principal principal) throws IOException { + + return ApiResponse.success(SuccessType.PUSH_ALARM_SUCCESS, fcmService.pushAlarm(request)); + } + + /** + * 동시에 여러 사람에게 푸시 알림을 보내보는 테스트용 API (주기적 알람 전송에 사용) + */ + @PostMapping("/parentchild") + @ResponseStatus(HttpStatus.OK) + public ApiResponse sendMultiScheduledTest() { + return ApiResponse.success(SuccessType.PUSH_ALARM_SUCCESS, fcmService.multipleSendByToken(FCMPushRequestDto.sendTodayQna("section", "question") ,93L)); + } + + +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMService.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMService.java new file mode 100644 index 00000000..62659213 --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMService.java @@ -0,0 +1,426 @@ +package sopt.org.umbba.notification.service.fcm; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.PessimisticLockingFailureException; +import org.springframework.http.HttpHeaders; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.parentchild.dao.ParentchildDao; +import sopt.org.umbba.domain.domain.parentchild.repository.ParentchildRepository; +import sopt.org.umbba.domain.domain.qna.QnA; +import sopt.org.umbba.domain.domain.qna.Question; +import sopt.org.umbba.domain.domain.qna.QuestionSection; +import sopt.org.umbba.domain.domain.qna.QuestionType; +import sopt.org.umbba.domain.domain.qna.repository.QnARepository; +import sopt.org.umbba.domain.domain.qna.repository.QuestionRepository; +import sopt.org.umbba.domain.domain.user.SocialPlatform; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; +import sopt.org.umbba.notification.service.fcm.dto.FCMMessage; +import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.PessimisticLockException; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; + +import static sopt.org.umbba.common.exception.ErrorType.NEED_MORE_QUESTION; +import static sopt.org.umbba.domain.domain.qna.QuestionType.MAIN; +import static sopt.org.umbba.domain.domain.qna.QuestionType.YET; + +/** + * 서버에서 파이어베이스로 전송이 잘 이루어지는지 테스트하기 위한 컨트롤러 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FCMService { + + @Value("${fcm.key.path}") + private String SERVICE_ACCOUNT_JSON; + @Value("${fcm.api.url}") + private String FCM_API_URL; + @Value("${fcm.topic}") + private String topic; + + private static ScheduledFuture scheduledFuture; + + private final UserRepository userRepository; + private final ParentchildRepository parentchildRepository; + private final QnARepository qnARepository; + private final QuestionRepository questionRepository; + private final ParentchildDao parentchildDao; + private final ObjectMapper objectMapper; + private final TaskScheduler taskScheduler; + private final PlatformTransactionManager transactionManager; + + + @PersistenceContext + private EntityManager em; + + + // Firebase에서 Access Token 가져오기 + private String getAccessToken() throws IOException { + + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(SERVICE_ACCOUNT_JSON).getInputStream()) + .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); + googleCredentials.refreshIfExpired(); + log.info("getAccessToken() - googleCredentials: {} ", googleCredentials.getAccessToken().getTokenValue()); + + return googleCredentials.getAccessToken().getTokenValue(); + } + + // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [단일 기기] + public String makeMessage(FCMPushRequestDto request) throws JsonProcessingException { + + FCMMessage fcmMessage = FCMMessage.builder() + .message(FCMMessage.Message.builder() + .token(request.getTargetToken()) // 1:1 전송 시 반드시 필요한 대상 토큰 설정 + .notification(FCMMessage.Notification.builder() + .title(request.getTitle()) + .body(request.getBody()) + .image(null) + .build()) + .build() + ).validateOnly(false) + .build(); + + return objectMapper.writeValueAsString(fcmMessage); + } + + // 따로 만들어둔 메세지 템플릿 이용해서 전송할 때 사용하는 알람 [Topic 구독] + String makeMessage(FCMPushRequestDto request, Long userId) throws FirebaseMessagingException, JsonProcessingException { + + User user = userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorType.INVALID_USER) + ); + + + FCMMessage fcmMessage = FCMMessage.builder() + .message(FCMMessage.Message.builder() + .token(user.getFcmToken()) +// .topic(topic) // 토픽 구동에서 반드시 필요한 설정 (token 지정 x) + .notification(FCMMessage.Notification.builder() + .title(request.getTitle()) + .body(request.getBody()) + .image(null) + .build()) + .build() + ).validateOnly(false) + .build(); + + return objectMapper.writeValueAsString(fcmMessage); + } + + // FCM Service에 메시지를 수신하는 함수 (헤더와 바디 직접 만들기) -> 상대 답변 알람 전송에 사용 + @Transactional + public String pushAlarm(FCMPushRequestDto request) throws IOException { + + String message = makeMessage(request); + sendPushMessage(message); + return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken(); + } + + public void sendPushMessage(String message) throws IOException { + + OkHttpClient client = new OkHttpClient(); + RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8")); + Request httpRequest = new Request.Builder() + .url(FCM_API_URL) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") + .build(); + + Response response = client.newCall(httpRequest).execute(); + + log.info("단일 기기 알림 전송 성공 ! successCount: 1 messages were sent successfully"); + log.info("알림 전송: {}", response.body().string()); + } + + // 다수의 기기(부모자식 ID에 포함된 유저 2명)에 알림 메시지 전송 -> 주기적 알림 전송에서 사용 + public String multipleSendByToken(FCMPushRequestDto request, Long parentchildId) { + + List tokenList = parentchildDao.findFcmTokensById(parentchildId); + + log.info("tokenList: {}🌈, {}🌈",tokenList.get(0), tokenList.get(1)); + + + MulticastMessage message = MulticastMessage.builder() + .setNotification(Notification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .setImage(null) + .build()) + .addAllTokens(tokenList) + .build(); + + log.info("message: {}", request.getTitle() +" "+ request.getBody()); + + try { + BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message); + log.info("다수 기기 알림 전송 성공 ! successCount: " + response.getSuccessCount() + " messages were sent successfully"); + log.info("알림 전송: {}", response.getResponses().toString()); + + return "알림을 성공적으로 전송했습니다. \ntargetUserId = 1." + tokenList.get(0) + ", \n\n2." + tokenList.get(1); + } catch (FirebaseMessagingException e) { + log.error("다수기기 푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage()); + throw new CustomException(ErrorType.FAIL_TO_SEND_PUSH_ALARM); + } + } + + public void schedulePushAlarm(String cronExpression, Long parentchildId) { + + scheduledFuture = taskScheduler.schedule(() -> { + + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionDefinition transactionDefinition = new DefaultTransactionDefinition(); + TransactionStatus transactionStatus = transactionManager.getTransaction(transactionDefinition); + + Parentchild parentchild = parentchildRepository.findById(parentchildId).orElseThrow( + () -> new CustomException(ErrorType.NOT_EXIST_PARENT_CHILD_RELATION) + ); + + log.info("성립된 부모자식- 초대코드: {}, 인덱스: {}", parentchild.getInviteCode(), parentchild.getCount()); + log.info("예약 작업 수행 전 remindCnt: {}", parentchild.getRemindCnt()); + + try { + if (!parentchild.getQnaList().isEmpty()) { + + QnA currentQnA = parentchild.getQnaList().get(parentchild.getCount() - 1); + List parentChildUsers = userRepository.findUserByParentChild(parentchild); + + if (parentChildUsers.stream(). + allMatch(user -> user.validateParentchild(parentChildUsers) && !user.getSocialPlatform().equals(SocialPlatform.WITHDRAW))) { + + parentchild.addRemindCnt(); // 리마인드 카운트는 항상 초기화! + Parentchild pc = em.merge(parentchild); + log.info("스케줄링 작업 내 addRemindCnt 후 remindCnt: {}", pc.getRemindCnt()); + + + // CASE 분류 - 1. 자식만 답변 2. 부모만 답변 3. 둘다 답변 X + if (!currentQnA.isParentAnswer() || !currentQnA.isChildAnswer()) { + + log.info("오늘의 질문 아직 답변하지 않은 유저 존재!!! - 부모"); + + Parentchild checkPc = pc; + int remindCnt = checkPc.getRemindCnt(); + String currentTopic = currentQnA.getQuestion().getTopic(); + + parentChildUsers.forEach(user -> { + if ((remindCnt == 1 || remindCnt == 3 || remindCnt == 6) && + ((user.isMeChild() && !currentQnA.isChildAnswer()) || + (!user.isMeChild() && !currentQnA.isParentAnswer()))) { + try { + if (remindCnt == 1) { + pushAlarm(FCMPushRequestDto.sendOpponentRemind(user.getFcmToken(), currentTopic, 24)); + } else if (remindCnt == 3) { + pushAlarm(FCMPushRequestDto.sendOpponentRemind(user.getFcmToken(), currentTopic, 72)); + } else if (remindCnt == 6) { + pushAlarm(FCMPushRequestDto.sendTodayQna( + user.getFcmToken(), + currentQnA.getQuestion().getSection().getValue(), + currentTopic)); + } + } catch (IOException e) { + log.error("❌❌❌ 리마인드 알림 전송 실패"); + } + } + }); + } + + // 부모와 자식 모두 답변한 경우 + else if (currentQnA.isParentAnswer() && currentQnA.isChildAnswer() && parentchild.getCount() != 7) { + + // 8일 이후 (7일 + 엔딩페이지 API 통신으로 추가된 1일) 에는 스케줄링을 돌며 QnA 직접 추가 + if (parentchild.getCount() >= 8) { + appendQna(parentchild); + } + + log.info("둘 다 답변함 다음 질문으로 ㄱ {}", parentchild.getCount()); + parentchild.addCount(); // 오늘의 질문 UP & 리마인드 카운트 초기화 + pc = em.merge(parentchild); + + log.info("스케줄링 작업 예약 내 addCount 후 count: {}", pc.getCount()); + + QnA todayQnA = parentchild.getQnaList().get(parentchild.getCount() - 1); + + log.info("\n Current QnA: {} \n Today QnA: {}", currentQnA.getId(), todayQnA.getId()); + if (todayQnA == null) { + log.error("{}번째 Parentchild의 QnaList가 존재하지 않음!", parentchild.getId()); + } + + log.info("FCMService - schedulePushAlarm() 실행"); + log.info("FCMService-schedulePushAlarm() topic: {}", todayQnA.getQuestion().getTopic()); + multipleSendByToken(FCMPushRequestDto.sendTodayQna( + todayQnA.getQuestion().getSection().getValue(), + todayQnA.getQuestion().getTopic()), parentchild.getId()); + } + } + } + transactionManager.commit(transactionStatus); + } catch (PessimisticLockingFailureException | PessimisticLockException e) { + transactionManager.rollback(transactionStatus); + } finally { + em.close(); + } + + // 현재 실행중인 쓰레드 확인 + log.info("Current Thread : {}", Thread.currentThread().getName()); + + }, new CronTrigger(cronExpression)); + } + + // 스케줄러에서 예약된 작업을 제거하는 메서드 + public static void clearScheduledTasks() { + if (scheduledFuture != null) { + log.info("이전 스케줄링 예약 취소!"); + scheduledFuture.cancel(false); + } + log.info("ScheduledFuture: {}", scheduledFuture); + } + + private void appendQna(Parentchild parentchild) { + List qnaList = getQnAListByParentchild(parentchild); + + // 1. 메인 타입과 미사용 타입에 대해서 불러오기 + List types = Arrays.asList(MAIN, YET); + + // 2. 내가 이미 주고받은 질문 제외하기 + List doneQuestionIds = qnaList.stream() + .map(qna -> qna.getQuestion().getId()) + .collect(Collectors.toList()); + + // 2.5 새로고침으로 버린 블랙리스트 질문 제외하기 + doneQuestionIds.addAll(parentchild.getQuestionBlackList()); + + // 5. 이 경우 아예 추가될 질문이 없으므로 예외 발생시킴 + List targetQuestions = questionRepository.findByTypeInAndIdNotIn(types, doneQuestionIds); + if (targetQuestions.isEmpty()) { + // 충실한 유저가 추가될 수 있는 질문을 모두 수행했을 경우, 기획 측에서 알 수 있도록 500 에러로 처리 + throw new CustomException(NEED_MORE_QUESTION); + } + + QuestionSection section = qnaList.get(parentchild.getCount() - 1).getQuestion().getSection(); + List differentSectionQuestions = targetQuestions.stream() + .filter(question -> !question.getSection().equals(section)) + .collect(Collectors.toList()); + + Random random = new Random(); + Question randomQuestion; + if (!differentSectionQuestions.isEmpty()) { + // 3. 최근에 주고받은 질문의 section과 다른 질문들 중에서 랜덤하게 추출 + randomQuestion = differentSectionQuestions.get(random.nextInt(differentSectionQuestions.size())); + } else { + // 4. 없다면 동일한 section의 질문 중에서라도 랜덤하게 추출 + List equalSectionQuestions = targetQuestions.stream() + .filter(question -> question.getSection().equals(section)) + .collect(Collectors.toList()); + randomQuestion = equalSectionQuestions.get(random.nextInt(equalSectionQuestions.size())); + } + + QnA newQnA = QnA.builder() + .question(randomQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + + qnARepository.save(newQnA); + parentchild.addQna(newQnA); + } + + private List getQnAListByParentchild(Parentchild parentchild) { + List qnaList = parentchild.getQnaList(); + if (qnaList == null || qnaList.isEmpty()) { + throw new CustomException(ErrorType.PARENTCHILD_HAVE_NO_QNALIST); + } + + return qnaList; + } + + /** + * 사용 안하는 함수들 + */ + + // Topic 구독 설정 - application.yml에서 topic명 관리 + // 단일 요청으로 최대 1000개의 기기를 Topic에 구독 등록 및 취소할 수 있다. + public void subscribe() throws FirebaseMessagingException { + // These registration tokens come from the client FCM SDKs. + // TODO Parentchild 테이블 탐색 후 주기적으로 알림 쏴주기 + List registrationTokens = Arrays.asList( + "YOUR_REGISTRATION_TOKEN_1", + // ... + "YOUR_REGISTRATION_TOKEN_n" + ); + + // Subscribe the devices corresponding to the registration tokens to the topic. + TopicManagementResponse response = FirebaseMessaging.getInstance().subscribeToTopic( + registrationTokens, topic); + + System.out.println(response.getSuccessCount() + " tokens were subscribed successfully"); + } + + // Topic 구독 취소 + public void unsubscribe() throws FirebaseMessagingException { + // These registration tokens come from the client FCM SDKs. + List registrationTokens = Arrays.asList( + "YOUR_REGISTRATION_TOKEN_1", + // ... + "YOUR_REGISTRATION_TOKEN_n" + ); + + // Unsubscribe the devices corresponding to the registration tokens from the topic. + TopicManagementResponse response = FirebaseMessaging.getInstance().unsubscribeFromTopic( + registrationTokens, topic); + + System.out.println(response.getSuccessCount() + " tokens were unsubscribed successfully"); + } + + // public void pushOpponentReply(String question, Long userId) { +// +// // 상대 측 유저의 FCM 토큰 찾기 +// User user = userRepository.findById(userId).orElseThrow( +// () -> new CustomException(ErrorType.INVALID_USER) +// ); +// +// try { +// log.info("상대방 답변 완료!"); +// String message = makeMessage(FCMPushRequestDto.sendOpponentReply(user.getFcmToken(), question), userId); +// sendPushMessage(message); +// } catch (IOException e) { +// log.error("푸시메시지 전송 실패 - IOException: {}", e.getMessage()); +// throw new CustomException(ErrorType.FAIL_TO_SEND_PUSH_ALARM); +// } catch (FirebaseMessagingException e) { +// log.error("푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage()); +// throw new CustomException(ErrorType.FAIL_TO_SEND_PUSH_ALARM); +// } +// } +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/dto/FCMMessage.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/dto/FCMMessage.java new file mode 100644 index 00000000..d7ebeafa --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/dto/FCMMessage.java @@ -0,0 +1,84 @@ +package sopt.org.umbba.notification.service.fcm.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * - Request + * { + * "validate_only": boolean, + * "message": { + * object (Message) + * } + * } + * + * + * - Message + * { + * "name": string, + * "data": { + * string: string, + * ... + * }, + * "notification": { ✅모든 플랫폼에서 사용할 기본 알림 템플릿 + * object (Notification) + * }, + * "android": { FCM 연결 서버를 통해 전송된 메시지에 대한 Android 전용 옵션 TODO 이 부분을 서버 측에서 설정해줘야 하는지? + * object (AndroidConfig) + * }, + * "webpush": { Web 푸시 알림을 위한 webpush 프로토콘 옵션 + * object (WebpushConfig) + * }, + * "apns": { Apple 푸시 알림 서비스 특정 옵션 TODO 이 부분을 서버 측에서 설정해줘야 하는지? + * object (ApnsConfig) + * }, + * "fcm_options": { 모든 플랫폼에서 사용할 FCM SDK 기능 옵션용 템플릿 + * object (FcmOptions) + * }, + * + * // Union field target can be only one of the following: + * "token": string, 메시지를 보낼 등록 토큰 (특정 클라이언트 대상) + * "topic": string, Topic 발행의 경우, 사용 + * "condition": string + * // End of list of possible types for union field target. + * } + */ +@Builder +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class FCMMessage { + + private boolean validateOnly; + private Message message; + + @Builder + @AllArgsConstructor + @Getter + public static class Message { + private Notification notification; // 모든 모바일 OS에 통합으로 사용할 수 있는 Notification + private String token; // 특정 디바이스(클라이언트)에 알림을 보내기 위한 토큰 +// private Data data; + private String topic; + } + + @Builder + @AllArgsConstructor + @Getter + public static class Notification { + private String title; + private String body; + private String image; + } + + @Builder + @AllArgsConstructor + @Getter + public static class Data { + private String name; + private String description; + } + + +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/scheduler/FCMScheduler.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/scheduler/FCMScheduler.java new file mode 100644 index 00000000..64c6e90a --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/scheduler/FCMScheduler.java @@ -0,0 +1,60 @@ +package sopt.org.umbba.notification.service.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import sopt.org.umbba.domain.domain.parentchild.dao.ParentchildDao; +import sopt.org.umbba.domain.domain.parentchild.repository.ParentchildRepository; +import sopt.org.umbba.domain.domain.user.SocialPlatform; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; +import sopt.org.umbba.notification.service.fcm.FCMService; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FCMScheduler { + + private final ParentchildRepository parentchildRepository; + private final UserRepository userRepository; + private final ParentchildDao parentchildDao; + private final FCMService fcmService; + + @Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul") // 초기값 + public String pushTodayQna() { + + log.info("오늘의 질문 알람 - 유저마다 보내는 시간 다름"); +// List tokenList = parentchildDao.findFcmTokensById(parentchildId); + + + parentchildRepository.findAll().stream() + .filter(pc -> { + List tokenList = parentchildDao.findFcmTokensById(pc.getId()); + List parentChildUsers = userRepository.findUserByParentChild(pc); + return tokenList != null && + tokenList.size() == 2 && + parentChildUsers.stream() + .allMatch(user -> user.validateParentchild(parentChildUsers) && !user.getSocialPlatform().equals(SocialPlatform.WITHDRAW)); + }) + .forEach(pc -> { + log.info(pc.getId() + "번째 Parentchild"); + String cronExpression = String.format("0 %s %s * * ?", pc.getPushTime().getMinute(), pc.getPushTime().getHour()); +// String cronExpression = String.format("*/20 * * * * *"); + log.info("cron: {}", cronExpression); + fcmService.schedulePushAlarm(cronExpression, pc.getId()); + }); + return "Today QnA messages were sent successfully"; + } + + +// @Scheduled(cron = "0 0 4 * * ?", zone = "Asia/Seoul") +// public String drink() { +// fcmService.multipleSendByToken(FCMPushRequestDto.sendTodayQna("술이슈", "새벽4시 술 먹을시간"), 3L); +// +// return "Today QnA messages were sent successfully"; +// } +} diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/slack/SlackApi.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/slack/SlackApi.java new file mode 100644 index 00000000..22249112 --- /dev/null +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/slack/SlackApi.java @@ -0,0 +1,107 @@ +package sopt.org.umbba.notification.service.slack; + +import com.slack.api.Slack; +import com.slack.api.model.block.Blocks; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.BlockCompositions; +import com.slack.api.webhook.WebhookPayloads; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Date; +import java.util.List; + +import static com.slack.api.model.block.composition.BlockCompositions.plainText; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlackApi { + + //application.yml 에 등록해놓은 webhookUrl + @Value("${slack.webhook.url}") + private String webhookUrl; + private final static String NEW_LINE = "\n"; + private final static String DOUBLE_NEW_LINE = "\n\n"; + + private StringBuilder sb = new StringBuilder(); + + + // Slack으로 알림 보내기 + public void sendAlert(Exception error, String requestMethod, String requestURI) throws IOException { + + // 현재 프로파일이 특정 프로파일이 아니면 알림보내지 않기 +// if (!env.getActiveProfiles()[0].equals("set1")) { +// return; +// } + + // 메시지 내용인 LayoutBlock List 생성 + List layoutBlocks = generateLayoutBlock(error, requestMethod, requestURI); + + // 슬랙의 send API과 webhookURL을 통해 생성한 메시지 내용 전송 + Slack.getInstance().send(webhookUrl, WebhookPayloads + .payload(p -> + // 메시지 전송 유저명 + p.username("Exception is detected 🚨") + // 메시지 전송 유저 아이콘 이미지 URL + .iconUrl("https://yt3.googleusercontent.com/ytc/AGIKgqMVUzRrhoo1gDQcqvPo0PxaJz7e0gqDXT0D78R5VQ=s900-c-k-c0x00ffffff-no-rj") + // 메시지 내용 + .blocks(layoutBlocks))); + } + + // 전체 메시지가 담긴 LayoutBlock 생성 + private List generateLayoutBlock(Exception error, String requestMethod, String requestURI) { + return Blocks.asBlocks( + getHeader("서버 측 오류로 예상되는 예외 상황이 발생하였습니다."), + Blocks.divider(), + getSection(generateErrorMessage(error)), + Blocks.divider(), + getSection(generateErrorPointMessage(requestMethod, requestURI)), + Blocks.divider(), + // 이슈 생성을 위해 프로젝트의 Issue URL을 입력하여 바로가기 링크를 생성 + getSection("") + ); + } + + // 예외 정보 메시지 생성 + private String generateErrorMessage(Exception error) { + sb.setLength(0); + sb.append("*[🔥 Exception]*" + NEW_LINE + error.toString() + DOUBLE_NEW_LINE); + sb.append("*[📩 From]*" + NEW_LINE + readRootStackTrace(error) + DOUBLE_NEW_LINE); + + return sb.toString(); + } + + // HttpServletRequest를 사용하여 예외발생 요청에 대한 정보 메시지 생성 + private String generateErrorPointMessage(String requestMethod, String requestURI) { + sb.setLength(0); + sb.append("*[🧾세부정보]*" + NEW_LINE); + sb.append("Request URL : " + requestURI + NEW_LINE); + sb.append("Request Method : " + requestMethod + NEW_LINE); + sb.append("Request Time : " + new Date() + NEW_LINE); + + return sb.toString(); + } + + // 예외발생 클래스 정보 return + private String readRootStackTrace(Exception error) { + return error.getStackTrace()[0].toString(); + } + + // 에러 로그 메시지의 제목 return + private LayoutBlock getHeader(String text) { + return Blocks.header(h -> h.text( + plainText(pt -> pt.emoji(true) + .text(text)))); + } + + // 에러 로그 메시지 내용 return + private LayoutBlock getSection(String message) { + return Blocks.section(s -> + s.text(BlockCompositions.markdownText(message))); + } +} \ No newline at end of file diff --git a/umbba-notification/src/main/resources/application-dev.yml b/umbba-notification/src/main/resources/application-dev.yml new file mode 100644 index 00000000..4ff31075 --- /dev/null +++ b/umbba-notification/src/main/resources/application-dev.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: dev + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_DEV} + username: ${DB_USER_DEV} + password: ${DB_PWD_DEV} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_DEV} + secretKey: ${CLOUD_SECRET_DEV} + region: + static: ${CLOUD_REGION_DEV} + s3: + bucket: ${BUCKET_NAME_DEV} + bucketImg: ${IMG_BUCKET_DEV} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_DEV} + url: ${SQS_URL_DEV} + +server: + port: 8083 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_DEV} + +slack: + webhook: + url: ${SLACK_URL_DEV} \ No newline at end of file diff --git a/umbba-notification/src/main/resources/application-local.yml b/umbba-notification/src/main/resources/application-local.yml new file mode 100644 index 00000000..9342d690 --- /dev/null +++ b/umbba-notification/src/main/resources/application-local.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: local + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_LOCAL} + username: ${DB_USER_LOCAL} + password: ${DB_PWD_LOCAL} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_LOCAL} + secretKey: ${CLOUD_SECRET_LOCAL} + region: + static: ${CLOUD_REGION_LOCAL} + s3: + bucket: ${BUCKET_NAME_LOCAL} + bucketImg: ${IMG_BUCKET_LOCAL} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_LOCAL} + url: ${SQS_URL_LOCAL} + +server: + port: 9092 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_LOCAL} + +slack: + webhook: + url: ${SLACK_URL_LOCAL} \ No newline at end of file diff --git a/umbba-notification/src/main/resources/application-prod.yml b/umbba-notification/src/main/resources/application-prod.yml new file mode 100644 index 00000000..35b7aec6 --- /dev/null +++ b/umbba-notification/src/main/resources/application-prod.yml @@ -0,0 +1,55 @@ +spring: + config: + activate: + on-profile: prod + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL_PROD} + username: ${DB_USER_PROD} + password: ${DB_PWD_PROD} + hikari: + pool-name: Hikari 커넥션 풀 # Pool + connection-timeout: 30000 # 30초(default: 30초) + maximum-pool-size: 10 # default: 10개 + max-lifetime: 600000 # 10분(default: 30분) + leak-detection-threshold: 3500 # default: 0(이용X) + + jpa: + show-sql: false + hibernate: + ddl-auto: update + ejb: + naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + properties: + hibernate: + format_sql: true + +cloud: + aws: + credentials: + accessKey: ${CLOUD_ACCESS_PROD} + secretKey: ${CLOUD_SECRET_PROD} + region: + static: ${CLOUD_REGION_PROD} + s3: + bucket: ${BUCKET_NAME_PROD} + bucketImg: ${IMG_BUCKET_PROD} + stack: + auto: false + sqs: + notification: + name: ${SQS_NAME_PROD} + url: ${SQS_URL_PROD} + +server: + port: 8083 + +kakao: + client-id: ${KAKAO_ID} + authorization-grant-type: authorization_code + redirect-uri: ${KAKAO_REDIRECT_PROD} + +slack: + webhook: + url: ${SLACK_URL_PROD} \ No newline at end of file diff --git a/umbba-notification/src/main/resources/application.yml b/umbba-notification/src/main/resources/application.yml new file mode 100644 index 00000000..b0c4554e --- /dev/null +++ b/umbba-notification/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + profiles: + active: local + +apple: + iss: ${APPLE_ISS} + client-id: ${APPLE_ID} + +jwt: + secret: ${JWT_SECRET} + +fcm: + key: + path: ${FCM_JSON_PATH} + scope: ${FCM_SCOPE} + # firebase-create-scoped: "https://www.googleapis.com/auth/firebase.messaging" + api: + url: ${FCM_API_URL} + topic: + "qna_notification" + +logging: + level: + com: + amazonaws: + util: + EC2MetadataUtils: error \ No newline at end of file diff --git a/umbba-notification/src/main/resources/bootstrap.yml b/umbba-notification/src/main/resources/bootstrap.yml new file mode 100644 index 00000000..b03fe132 --- /dev/null +++ b/umbba-notification/src/main/resources/bootstrap.yml @@ -0,0 +1,7 @@ +aws: + secretsmanager: + name: umbba-secret +cloud: + aws: + region: + static: ap-northeast-2 \ No newline at end of file