diff --git a/.github/workflows/dev-CD.yml b/.github/workflows/dev-CD.yml index abe6fbf0..80d19424 100644 --- a/.github/workflows/dev-CD.yml +++ b/.github/workflows/dev-CD.yml @@ -1,9 +1,13 @@ -name: DEV Dockerhub Push +name: Push Image to Amazon ECR on: push: branches: - dev +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} + GITHUB_SHA_FIX: ${{ github.sha }} jobs: build: @@ -40,117 +44,41 @@ jobs: run: | cd ./src/main/resources touch ./application-secret.yml - echo "${{ secrets.APPLICATION_SECRET}}" > ./application-secret.yml + echo "${{ secrets.APPLICATION_SECRET }}" > ./application-secret.yml shell: bash # dev.yml 반영 - name: Make application-dev.yml run: | - cd ./src/main/resources - echo "${{ secrets.APPLICATION_DEV}}" > ./application-dev.yml + cd ./src/main/resources + echo "${{ secrets.APPLICATION_DEV }}" > ./application-release.yml shell: bash # Gradle BootJar - name: BootJar with Gradle run: ./gradlew clean bootJar -Dspring.profiles.active=dev - # Docker Image Push - - name: Docker Image push - run: | - docker login -u ${{ secrets.DEV_DOCKERHUB_USERNAME }} -p ${{ secrets.DEV_DOCKERHUB_PASSWORD }} - docker build -t ${{ secrets.DEV_DOCKERHUB_USERNAME}}/${{ secrets.DEV_DOCKERHUB_REPOSITORY}} ./ - docker push ${{ secrets.DEV_DOCKERHUB_USERNAME }}/${{ secrets.DEV_DOCKERHUB_REPOSITORY}} - -#name: Beanstalk Deploy -# -#on: -# push: -# branches: -# - dev -# -#jobs: -# build: -# # ubuntu 버전 지정 -# runs-on: ubuntu-22.04 -# steps: -# # Checkout 진행 -# - uses: actions/checkout@v3 -# -# # JDK 11 설치 -# - name: Set up JDK 11 -# uses: actions/setup-java@v3 + # Configure AWS Credentials by using IAM inform +# - name: Configure AWS credentials +# uses: aws-actions/configure-aws-credentials@v1 # with: -# java-version: '11' -# distribution: 'temurin' -# -# # Gradle 캐싱 -# - name: Gradle Caching -# uses: actions/cache@v3 -# with: -# path: | -# ~/.gradle/caches -# ~/.gradle/wrapper -# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} -# restore-keys: | -# ${{ runner.os }}-gradle- -# -# # Gradle 권한 부여 -# - name: Grant execute permission for gradlew -# run: chmod +x gradlew -# -# # yml 반영 -# - name: Make application-secret.yml -# run: | -# cd ./src/main/resources -# touch ./application-secret.yml -# echo "${{ secrets.APPLICATION_SECRET}}" > ./application-secret.yml -# shell: bash -# -## - name: Make firebase json -## run: | -## mkdir -p ./src/main/resources/firebase -## echo "${{ secrets.FIREBASE_DEV_ADMIN}}" > ./src/main/resources/firebase/firebase-dev-admin.json -## shell: bash -# -## # makeFiles.config 반영 -## - name: Make 00-makeFiles.config -## run: | -## cd ./.ebextensions -## touch ./00-makeFiles.config -## echo "${{ secrets.DEV_MAKEFILES}}" > ./00-makeFiles.config -## shell: bash -# -# # Gradle BootJar -# - name: BootJar with Gradle -# run: ./gradlew clean bootJar -# -# # 현재 시간 반영 -# - name: Get current time -# uses: 1466587594/get-current-time@v2 -# id: current-time -# with: -# format: YYYY-MM-DDTHH-mm-ss -# utcOffset: "+09:00" -# -# # grandle build를 통해 만들어진 jar를 beanstalk에 배포하기 위한 zip 파일로 만드는 것 -# - name: Generate deployment package +# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} # 나의 ECR 정보 +# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +# aws-region: ${{ env.AWS_REGION }} + + # Login to ECR +# - name: Login to Amazon ECR +# id: login-ecr +# uses: aws-actions/amazon-ecr-login@v1 + + # Docker Image Push to ECR and Run container with Image pull from ECR +# - name: Build, tag, and push image to Amazon ECR +# id: build-image +# env: +# ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} +# IMAGE_TAG: ${{ env.GITHUB_SHA_FIX }} # run: | -# mkdir -p deploy -# cp build/libs/*.jar deploy/application.jar -# cp Procfile deploy/Procfile -# cp -r .ebextensions deploy/.ebextensions -# cp -r .platform deploy/.platform -# cd deploy && zip -r deploy.zip . -# -# # Beanstalk Deploy 플러그인 사용 -# - name: Beanstalk Deploy -# uses: einaregilsson/beanstalk-deploy@v14 -# with: -# aws_access_key: ${{ secrets.AWS_ACCESS_KEY }} # github secrets로 등록한 값 사용 -# aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # github secrets로 등록한 값 사용 -# application_name: Gwalit-dev # EB application 이름 -# environment_name: Gwalit-dev-env # EB environment 이름 -# version_label: Github Action-${{steps.current-time.outputs.formattedTime}} # 배포 버전은 타임스탬프를 이용하여 구분 -# region: ap-northeast-2 -# deployment_package: deploy/deploy.zip -# wait_for_environment_recovery: 100 # default wait time은 30초이며, 필자의 EB가 느려서 180초로 지정했습니다(지정 안하면 간혹 timeout 발생). \ No newline at end of file +# # Build a docker container and push it to ECR so that it can be deployed to ECS. +# docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . +# docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG +# echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" \ No newline at end of file diff --git a/.github/workflows/release-CD.yml b/.github/workflows/release-CD.yml index 9f61ffbb..40653c0c 100644 --- a/.github/workflows/release-CD.yml +++ b/.github/workflows/release-CD.yml @@ -1,10 +1,15 @@ -name: Beanstalk Deploy +name: Deploy release version on: push: branches: - release +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} + GITHUB_SHA: ${{ github.sha }} + jobs: build: # ubuntu 버전 지정 @@ -40,137 +45,56 @@ jobs: run: | cd ./src/main/resources touch ./application-secret.yml - echo "${{ secrets.APPLICATION_SECRET}}" > ./application-secret.yml + echo "${{ secrets.APPLICATION_SECRET }}" > ./application-secret.yml shell: bash # release.yml 반영 - name: Make application-release.yml run: | cd ./src/main/resources - echo "${{ secrets.GCP_APPLICATION_RELEASE}}" > ./application-release.yml + echo "${{ secrets.APPLICATION_RELEASE }}" > ./application-release.yml shell: bash # Gradle BootJar - name: BootJar with Gradle run: ./gradlew clean bootJar -Dspring.profiles.active=release - # Docker Image Push - - name: Docker Image push + # Configure AWS Credentials by using IAM inform + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} # 나의 ECR 정보 + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + # Login to ECR + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + # Docker Image Push to ECR and Run container with Image pull from ECR + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ env.GITHUB_SHA }} run: | - docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }} - docker build -t ${{ secrets.DOCKERHUB_USERNAME}}/${{ secrets.DOCKERHUB_REPOSITORY}} ./ - docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY}} + # Build a docker container and push it to ECR so that it can be deployed to ECS. + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" # Docker Compose - name: Docker Compose - uses: appleboy/ssh-action@master + uses: appleboy/ssh-action@v1.0.3 with: - host: ${{ secrets.GCP_SERVER_IP }} + host: ${{ secrets.AWS_SERVER_IP }} username: ${{ secrets.SSH_USERNAME }} key: ${{ secrets.SSH_PRIVATE_KEY }} - passphrase: ${{ secrets.SSH_PASSPHRASE }} - envs: GITHUB_SHA script: | - sudo docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }} - sudo docker-compose stop ${{ secrets.DOCKER_SERVICE_NAME }} - sudo docker-compose rm -f ${{ secrets.DOCKER_SERVICE_NAME }} - sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY}} - sudo docker tag ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY}} ${{ secrets.DOCKER_IMAGE_NAME }} - sudo docker-compose up -d - - -#name: Beanstalk Deploy -# -#on: -# push: -# branches: -# - release -# -#jobs: -# build: -# # ubuntu 버전 지정 -# runs-on: ubuntu-22.04 -# steps: -# # Checkout 진행 -# - uses: actions/checkout@v3 -# -# # JDK 11 설치 -# - name: Set up JDK 11 -# uses: actions/setup-java@v3 -# with: -# java-version: '11' -# distribution: 'temurin' -# -# # Gradle 캐싱 -# - name: Gradle Caching -# uses: actions/cache@v3 -# with: -# path: | -# ~/.gradle/caches -# ~/.gradle/wrapper -# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} -# restore-keys: | -# ${{ runner.os }}-gradle- -# -# # Gradle 권한 부여 -# - name: Grant execute permission for gradlew -# run: chmod +x gradlew -# -# # secret.yml 반영 -# - name: Make application-secret.yml -# run: | -# cd ./src/main/resources -# touch ./application-secret.yml -# echo "${{ secrets.APPLICATION_SECRET}}" > ./application-secret.yml -# shell: bash -# -# # release.yml 반영 -# - name: Make application-release.yml -# run: | -# cd ./src/main/resources -# echo "${{ secrets.APPLICATION_RELEASE}}" > ./application-release.yml -# shell: bash -# -# -## # makeFiles.config 반영 -## - name: Make 00-makeFiles.config -## run: | -## cd ./.ebextensions -## touch ./00-makeFiles.config -## echo "${{ secrets.RELEASE_MAKEFILES}}" > ./00-makeFiles.config -## shell: bash -# -# # Gradle BootJar -# - name: BootJar with Gradle -# run: ./gradlew clean bootJar -Dspring.profiles.active=release -# -# # 현재 시간 반영 -# - name: Get current time -# uses: 1466587594/get-current-time@v2 -# id: current-time -# with: -# format: YYYY-MM-DDTHH-mm-ss -# utcOffset: "+09:00" -# -# # grandle build를 통해 만들어진 jar를 beanstalk에 배포하기 위한 zip 파일로 만드는 것 -# - name: Generate deployment package -# run: | -# mkdir -p deploy -# cp build/libs/*.jar deploy/application.jar -# cp Procfile deploy/Procfile -# cp -r .ebextensions deploy/.ebextensions -# cp -r .platform deploy/.platform -# cd deploy && zip -r deploy.zip . -# -# # Beanstalk Deploy 플러그인 사용 -# - name: Beanstalk Deploy -# uses: einaregilsson/beanstalk-deploy@v14 -# with: -# aws_access_key: ${{ secrets.AWS_ACCESS_KEY }} # github secrets로 등록한 값 사용 -# aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # github secrets로 등록한 값 사용 -# application_name: Gwalit-release # EB application 이름 -# environment_name: Gwalit-release-env # EB environment 이름 -# version_label: Github Action-${{steps.current-time.outputs.formattedTime}} # 배포 버전은 타임스탬프를 이용하여 구분 -# region: ap-northeast-2 -# deployment_package: deploy/deploy.zip -# wait_for_environment_recovery: 100 # default wait time은 30초이며, 필자의 EB가 느려서 180초로 지정했습니다(지정 안하면 간혹 timeout 발생). \ No newline at end of file + aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }} + docker-compose stop ${{ secrets.DOCKER_SERVICE_NAME }} + docker-compose rm -f ${{ secrets.DOCKER_SERVICE_NAME }} + docker pull ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.GITHUB_SHA_FIX }} + docker tag ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.GITHUB_SHA_FIX }} ${{ secrets.DOCKER_IMAGE_NAME }} + docker-compose up -d diff --git a/README.md b/README.md index d016a7a7..b8c32f43 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,79 @@ -# gwalit-server -과릿 서버 +# 과릿 - 선생님과 학생이 함께 쓰는 쉬운 수업 관리 서비스 +![image](https://github.com/self-runner/gwalit-server/assets/76556999/a25468fb-dc2c-4ea9-8724-8908141aa06b) + +## 과릿 사용하기 +- [안드로이드 설치하기](https://bit.ly/gwarit-android) +- [애플 설치하기](https://bit.ly/gwarit-apple) +- [과릿 공지사항](https://wjdwls.notion.site/Gwarit-c2f1540c688a4e85ab132be6992c23cb?pvs=4) +- [과릿 콘텐츠자료실](https://wjdwls.notion.site/5c7069be76044bf2b6ba5464a4841134?pvs=4) +- [과릿 인스타그램](https://www.instagram.com/gwa_rit) + +## 개발 +### 기술 스택 +**Language&Framework** +- Java11/Spring Framework (Spring Boot 2.7.13) +- Spring Data JPA, QueryDsl +- Spring Batch +- Swagger + +**Database** +- MySQL +- Redis + +**Infra** +- AWS Elastic Beanstalk, EC2, AWS RDS, AWS LB, Nginx, AWS S3 +- GCP GCE, ALB, GCS, Cloud SQL +- Git, Github Actions + +**ETC** +- Slack +- Sentry +- JWT +- Nave SENS API, CoolSMS +- FCM, Jasypt + +### 주요 개발 내용 +- 스프링 배치를 활용한 매일 오전 9시 지정 알림 발송:
금일의 수업 여부를 알려주기 위해, 스프링 배치를 활용하여 매일 오전 9시 대상 사용자에 한해 FCM 알림 발송
 +- Bulk Insert를 활용한 API 성능 개선:
JPA의 saveAll의 기존 코드를 JdbcTemplate의 Bulk Insert로 실행 시간 60% 감소 (최대 3초 -> 최대 1.2초) +- 비동기 설정을 통해 FCM 전송 성능 개선:
FCM 알림 발송 로직 비동기 처리를 통해 실행 시간 25% 감소 (최대 2초 -> 최대 1.5초)
 +- [2시간 주기의 Active Health Check
Github Actions, Slack을 활용하여 2시간 주기마다 서버 헬스체크 진행](https://velog.io/@dl-00-e8/%EC%84%9C%EB%B2%84-%ED%97%AC%EC%8A%A4%EC%B2%B4%ED%81%AC-Github-Actions%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%9C%EB%B2%84-%ED%97%AC%EC%8A%A4%EC%B2%B4%ED%81%AC) +- Sentry를 활용한 슬로우 쿼리, 에러 모니터링
AOP를 활용한 슬로우 쿼리 측정 및 서버 에러 발생 시, Sentry 이벤트와 Slack 알림 발송 설정
 +- 사용자별 테마 적용을 위한 데이터베이스 설계
사용자가 클래스별 테마를 설정할 수 있도록 참조 테이블을 활용하여 이름/색상 정보 관리 +- 운영 환경과 개발 환경 분리 구성(AWS 활용) +- [서버 비용 최소화를 위한 AWS에서 GCP로의 인프라 전환 (다운타임 최소화)](https://velog.io/@dl-00-e8/GCP-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8%EB%A5%BC-%EB%8F%84%EC%BB%A4%EC%97%90-%EB%84%A3%EC%96%B4%EC%84%9C-GCE%EC%97%90-%EC%98%AC%EB%A0%A4%EB%B3%B4%EA%B2%A0%EB%82%98) +- [Naver SENS API의 개인 대상 서비스 종료로 인한, CoolSMS 전환](https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Cool-SMS-%EC%A0%84%ED%99%98%EA%B8%B0) + +### ERD +**V3 (2023. 12)** +![image](https://github.com/self-runner/gwalit-server/assets/76556999/df4ff755-7f50-4b72-ac9d-289dec16064a) +
+V2 (2023. 11) +
+ +![gwalitdb](https://github.com/self-runner/gwalit-server/assets/76556999/4f5639b5-443e-4439-b52d-7a597485b391) + +
+
+
+V1 (2023. 06) +
+ +![image](https://github.com/self-runner/gwalit-server/assets/76556999/787d2997-8022-41b8-8409-765390e5262b) + +
+
+ + + + +### Infra Architecture +**V2 (2023. 01 ~ NOW)** +![gwalit-architecture-gcp 1](https://github.com/self-runner/gwalit-server/assets/76556999/59904463-e604-4a5b-b524-c7e337032f68) +
+V1 (2023. 06 ~ 2023. 12) +
+ +![image](https://github.com/self-runner/gwalit-server/assets/76556999/4c9cccf0-6388-4a83-a3bb-db2470e5bf8b) + +
+
diff --git a/build.gradle b/build.gradle index 76f866fc..6e69d3d3 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,9 @@ dependencies { // FCM implementation 'com.google.firebase:firebase-admin:9.2.0' + + // CoolSMS + implementation 'net.nurigo:javaSDK:2.2' } tasks.named('test') { diff --git a/src/main/java/com/selfrunner/gwalit/GwalitApplication.java b/src/main/java/com/selfrunner/gwalit/GwalitApplication.java index fc7f5928..adb28e61 100644 --- a/src/main/java/com/selfrunner/gwalit/GwalitApplication.java +++ b/src/main/java/com/selfrunner/gwalit/GwalitApplication.java @@ -6,7 +6,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; @EnableAsync @EnableJpaAuditing diff --git a/src/main/java/com/selfrunner/gwalit/domain/board/repository/BoardRepositoryImpl.java b/src/main/java/com/selfrunner/gwalit/domain/board/repository/BoardRepositoryImpl.java index cb12b089..d4e0273f 100644 --- a/src/main/java/com/selfrunner/gwalit/domain/board/repository/BoardRepositoryImpl.java +++ b/src/main/java/com/selfrunner/gwalit/domain/board/repository/BoardRepositoryImpl.java @@ -41,7 +41,7 @@ public Slice findBoardPaginationByCategory(Member m, Long lectureI .leftJoin(lecture).on(board.lecture.lectureId.eq(lecture.lectureId)) .leftJoin(member).on(board.member.memberId.eq(member.memberId)) .leftJoin(reply).on(board.boardId.eq(reply.board.boardId)) - .where(board.lecture.lectureId.eq(lectureId), eqCursorAndCursorCreatedAt(cursor, cursorCreatedAt), checkCategory(category), board.deletedAt.isNull()) + .where(board.lecture.lectureId.eq(lectureId), eqCursorAndCursorCreatedAt(cursor, cursorCreatedAt), checkCategory(category), board.deletedAt.isNull(), reply.deletedAt.isNull()) .orderBy(board.createdAt.desc(), board.boardId.asc()) .groupBy(board.boardId) .limit(pageable.getPageSize() + 1) diff --git a/src/main/java/com/selfrunner/gwalit/domain/lecture/service/LectureService.java b/src/main/java/com/selfrunner/gwalit/domain/lecture/service/LectureService.java index 36a13804..b421f82b 100644 --- a/src/main/java/com/selfrunner/gwalit/domain/lecture/service/LectureService.java +++ b/src/main/java/com/selfrunner/gwalit/domain/lecture/service/LectureService.java @@ -25,7 +25,7 @@ import com.selfrunner.gwalit.global.common.Schedule; import com.selfrunner.gwalit.global.exception.ErrorCode; import com.selfrunner.gwalit.global.util.fcm.FCMClient; -import com.selfrunner.gwalit.global.util.sms.SmsClient; +import com.selfrunner.gwalit.global.util.sms.CoolSMSClient; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,7 +52,7 @@ public class LectureService { private final LessonRepository lessonRepository; private final LessonJdbcRepository lessonJdbcRepository; private final HomeworkRepository homeworkRepository; - private final SmsClient smsClient; + private final CoolSMSClient smsClient; private final FCMClient fcmClient; @Transactional @@ -285,7 +285,7 @@ public void inviteStudent(Member member, Long lectureId, PostInviteReq postInvit Member check = memberRepository.findNotFakeByPhoneAndType(postInviteReq.getPhone(), MemberType.STUDENT).orElse(null); if(check != null) { if(check.getState().equals(MemberState.INVITE)) { - smsClient.sendInvitation(member.getName(), lectureName, postInviteReq, Boolean.TRUE); + smsClient.sendInvitation(postInviteReq, Boolean.TRUE); } if(check.getState().equals(MemberState.ACTIVE) && check.getToken() != null) { // smsClient.sendInvitation(member.getName(), lectureName,postInviteReq, Boolean.FALSE); @@ -302,7 +302,7 @@ public void inviteStudent(Member member, Long lectureId, PostInviteReq postInvit memberAndLectureRepository.save(studentAndLecture); } if(check == null) { - smsClient.sendInvitation(member.getName(), memberAndLecture.getLecture().getName(),postInviteReq, Boolean.TRUE); + smsClient.sendInvitation(postInviteReq, Boolean.TRUE); Member student = postInviteReq.toEntity(); memberRepository.save(student); MemberAndLecture studentAndLecture = MemberAndLecture.builder() diff --git a/src/main/java/com/selfrunner/gwalit/domain/member/service/AuthService.java b/src/main/java/com/selfrunner/gwalit/domain/member/service/AuthService.java index 60319ee5..82a4f816 100644 --- a/src/main/java/com/selfrunner/gwalit/domain/member/service/AuthService.java +++ b/src/main/java/com/selfrunner/gwalit/domain/member/service/AuthService.java @@ -22,7 +22,7 @@ import com.selfrunner.gwalit.global.util.jwt.TokenDto; import com.selfrunner.gwalit.global.util.jwt.TokenProvider; import com.selfrunner.gwalit.global.util.redis.RedisClient; -import com.selfrunner.gwalit.global.util.sms.SmsClient; +import com.selfrunner.gwalit.global.util.sms.CoolSMSClient; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,7 +40,7 @@ @RequiredArgsConstructor public class AuthService { - private final SmsClient smsClient; + private final CoolSMSClient smsClient; private final RedisClient redisClient; private final TokenProvider tokenProvider; private final MemberRepository memberRepository; @@ -53,13 +53,11 @@ public class AuthService { public void sendAuthorizationCode(PostAuthPhoneReq postAuthPhoneReq) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException, URISyntaxException { // Business Logic - 테스트계정은 문자 발송이 되지 않도록 수정 if(!postAuthPhoneReq.getPhone().equals("01011111111")) { - System.out.println("test"); String authorizationCode = smsClient.sendAuthorizationCode(postAuthPhoneReq); redisClient.setValue(postAuthPhoneReq.getPhone(), authorizationCode, 300L); } - // Response } public void checkAuthorizationCode(PostAuthCodeReq postAuthCodeReq) { diff --git a/src/main/java/com/selfrunner/gwalit/global/util/sms/CoolSMSClient.java b/src/main/java/com/selfrunner/gwalit/global/util/sms/CoolSMSClient.java new file mode 100644 index 00000000..aeea68f4 --- /dev/null +++ b/src/main/java/com/selfrunner/gwalit/global/util/sms/CoolSMSClient.java @@ -0,0 +1,130 @@ +package com.selfrunner.gwalit.global.util.sms; + +import com.selfrunner.gwalit.domain.lecture.dto.request.PostInviteReq; +import com.selfrunner.gwalit.domain.member.dto.request.PostAuthCodeReq; +import com.selfrunner.gwalit.domain.member.dto.request.PostAuthPhoneReq; +import net.nurigo.java_sdk.api.Message; +import net.nurigo.java_sdk.exceptions.CoolsmsException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +@Component +public class CoolSMSClient { + + @Value("${coolsms.api-key}") + private String apiKey; + @Value("${coolsms.api-secret}") + private String apiSecretKey; + @Value("${coolsms.sender-phone}") + private String senderPhone; + + // 단일 메시지 발송 예제 + public String sendAuthorizationCode(PostAuthPhoneReq postAuthPhoneReq) { + // 인증 번호 생성 + String authorizationCode = Integer.toString((int)(Math.random() * (999999 - 100000 + 1)) + 100000); + + Message coolsms = new Message(apiKey, apiSecretKey); + + HashMap params = new HashMap<>(); + params.put("to", postAuthPhoneReq.getPhone()); + params.put("from", senderPhone); + params.put("type", "SMS"); + params.put("text", "[과릿]" + "\n" + "인증번호: " + authorizationCode + "\n" + "인증 번호를 입력해주세요."); + params.put("app_version", "Gwarit 1.3.4"); + + try { + coolsms.send(params); + } catch (CoolsmsException e) { + throw new RuntimeException(e); + } + + return authorizationCode; + } + + public String sendTemporaryPassword(PostAuthCodeReq postAuthCodeReq) { + // 임시 비밀번호 발급 + char[] list = new char[] { + '1','2','3','4','5','6','7','8','9','0', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + 'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + '!','@','#','$','%','^','&','*','(',')'}; + + StringBuilder temporaryPassword = new StringBuilder(); + + // 숫자 하나 추가 + int idx = (int)(Math.random() * 10); // 0에서 9 사이의 인덱스 선택 + temporaryPassword.append(list[idx]); + + // 문자 하나 추가 + idx = (int)(Math.random() * 26) + 10; // 10에서 35 사이의 인덱스 선택 (알파벳 대문자) + temporaryPassword.append(list[idx]); + + // 특수 문자 하나 추가 + idx = (int)(Math.random() * 9) + 62; // 36에서 44 사이의 인덱스 선택 (특수 문자) + temporaryPassword.append(list[idx]); + + for(int i = 3; i < 10; i++) { + idx = (int) (Math.random() * (list.length)); + temporaryPassword.append(list[idx]); + } + // 문자열을 문자 리스트로 변환 + List charList = new ArrayList<>(); + for (char c : temporaryPassword.toString().toCharArray()) { + charList.add(c); + } + + // 문자 리스트를 섞기 + Collections.shuffle(charList); + + // 섞인 문자 리스트를 다시 문자열로 변환 + StringBuilder shuffledPassword = new StringBuilder(); + for (char c : charList) { + shuffledPassword.append(c); + } + + Message coolsms = new Message(apiKey, apiSecretKey); + + HashMap params = new HashMap<>(); + params.put("to", postAuthCodeReq.getPhone()); + params.put("from", senderPhone); + params.put("type", "SMS"); + params.put("text", "[과릿]" + "\n" + "임시비밀번호: " + "\n" + temporaryPassword + "\n" + "\n" + "로그인 이후 비밀번호를 변경해주세요."); + params.put("app_version", "Gwarit 1.3.4"); + + try { + coolsms.send(params); + } catch (CoolsmsException e) { + throw new RuntimeException(e); + } + + return temporaryPassword.toString(); + } + + public void sendInvitation(PostInviteReq postInviteReq, Boolean type) { + Message coolsms = new Message(apiKey, apiSecretKey); + + HashMap params = new HashMap<>(); + params.put("to", postInviteReq.getPhone()); + params.put("from", senderPhone); + params.put("type", "LMS"); + params.put("app_version", "Gwarit 1.3.4"); + + if(type.equals(Boolean.TRUE)) { + params.put("text", "[과릿]" + "\n" + "선생님으로부터 클래스 초대가 도착했습니다." + "\n" + "아래 링크를 통해 앱 설치 및 회원가입을 통해 수업에 참여해보세요!" + "\n" + "\n" + "안드로이드: " + "https://bit.ly/gwarit-android" + "\n" + "\n" + "애플: " + "https://bit.ly/gwarit-apple"); + } + if(type.equals(Boolean.FALSE)) { + params.put("text", "[과릿]" + "\n" + "선생님으로부터 클래스 초대가 도착했습니다." + "\n" + "아래 링크를 클릭 후 앱 열기를 통해 수업에 참여해보세요!" + "\n" + "\n" + "안드로이드: " + "https://bit.ly/gwarit-android" + "\n" + "\n" + "애플: " + "https://bit.ly/gwarit-apple"); + } + + try { + coolsms.send(params); + } catch (CoolsmsException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/selfrunner/gwalit/global/util/sms/SmsClient.java b/src/main/java/com/selfrunner/gwalit/global/util/sms/SmsClient.java deleted file mode 100644 index e19ccbe8..00000000 --- a/src/main/java/com/selfrunner/gwalit/global/util/sms/SmsClient.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.selfrunner.gwalit.global.util.sms; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.selfrunner.gwalit.domain.lecture.dto.request.PostInviteReq; -import com.selfrunner.gwalit.domain.member.dto.request.PostAuthCodeReq; -import com.selfrunner.gwalit.domain.member.dto.request.PostAuthPhoneReq; -import com.selfrunner.gwalit.global.util.sms.dto.SmsMessageDto; -import com.selfrunner.gwalit.global.util.sms.dto.SmsNaverReq; -import com.selfrunner.gwalit.global.util.sms.dto.SmsNaverRes; -import lombok.RequiredArgsConstructor; -import org.apache.tomcat.util.codec.binary.Base64; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URISyntaxException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class SmsClient { - @Value("${naver-sens-api-service-id}") - private String serviceId; - - @Value("${naver-sens-api-access-key}") - private String accessKey; - - @Value("${naver-sens-api-secret-key}") - private String secretKey; - - @Value("${naver-sens-api-sender-phone}") - private String senderPhone; - - public String sendAuthorizationCode(PostAuthPhoneReq postAuthPhoneReq) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException, URISyntaxException { - // 인증 번호 생성 - String authorizationCode = Integer.toString((int)(Math.random() * (999999 - 100000 + 1)) + 100000); - Long time = System.currentTimeMillis(); - - // API 요청 Header, Body 구성 - List smsMessageDtoList = new ArrayList<>(); - - smsMessageDtoList.add(new SmsMessageDto(postAuthPhoneReq.getPhone(), "[과릿] 인증번호: " + authorizationCode + "\n 인증 번호를 입력해주세요.")); - ObjectMapper objectMapper = new ObjectMapper(); - String jsonBody = objectMapper.writeValueAsString(new SmsNaverReq("SMS", this.senderPhone, authorizationCode, smsMessageDtoList)); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("x-ncp-apigw-timestamp", time.toString()); - headers.set("x-ncp-iam-access-key", this.accessKey); - String sig = makeSignature(time); // 암호화 - headers.set("x-ncp-apigw-signature-v2", sig); - - // Header 포함 전송 - HttpEntity body = new HttpEntity<>(jsonBody, headers); - - RestTemplate restTemplate = new RestTemplate(); - restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory()); - SmsNaverRes smsNaverRes = restTemplate.postForObject(new URI("https://sens.apigw.ntruss.com/sms/v2/services/"+this.serviceId+"/messages"), body, SmsNaverRes.class); - - // 실페 시, Error 던지기 - if(!smsNaverRes.getStatusCode().equals("202")) { - throw new RuntimeException("문자 전송에 실패했습니다."); - } - - // 성공 시, authorizationCode 반환 - return authorizationCode; - } - - public String sendTemporaryPassword(PostAuthCodeReq postAuthCodeReq) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException, URISyntaxException { - // 임시 비밀번호 발급 - char list[] = new char[] { - '1','2','3','4','5','6','7','8','9','0', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - 'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - '!','@','#','$','%','^','&','*','(',')'}; - - String temporaryPassword = ""; - - // 숫자 하나 추가 - int idx = (int)(Math.random() * 10); // 0에서 9 사이의 인덱스 선택 - temporaryPassword += list[idx]; - - // 문자 하나 추가 - idx = (int)(Math.random() * 26) + 10; // 10에서 35 사이의 인덱스 선택 (알파벳 대문자) - temporaryPassword += list[idx]; - - // 특수 문자 하나 추가 - idx = (int)(Math.random() * 9) + 62; // 36에서 44 사이의 인덱스 선택 (특수 문자) - temporaryPassword += list[idx]; - - for(Integer i = 3; i < 10; i++) { - idx = (int) (Math.random() * (list.length)); - temporaryPassword += list[idx]; - } - // 문자열을 문자 리스트로 변환 - List charList = new ArrayList<>(); - for (char c : temporaryPassword.toCharArray()) { - charList.add(c); - } - - // 문자 리스트를 섞기 - Collections.shuffle(charList); - - // 섞인 문자 리스트를 다시 문자열로 변환 - StringBuilder shuffledPassword = new StringBuilder(); - for (char c : charList) { - shuffledPassword.append(c); - } - Long time = System.currentTimeMillis(); - - // API 요청 Header, Body 구성 - List smsMessageDtoList = new ArrayList<>(); - - smsMessageDtoList.add(new SmsMessageDto(postAuthCodeReq.getPhone(), "[과릿] 임시비밀번호: " + "\n" + temporaryPassword + "\n" + "\n" + "로그인 이후 비밀번호를 변경해주세요.")); - ObjectMapper objectMapper = new ObjectMapper(); - String jsonBody = objectMapper.writeValueAsString(new SmsNaverReq("SMS", this.senderPhone, temporaryPassword, smsMessageDtoList)); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("x-ncp-apigw-timestamp", time.toString()); - headers.set("x-ncp-iam-access-key", this.accessKey); - String sig = makeSignature(time); // 암호화 - headers.set("x-ncp-apigw-signature-v2", sig); - - // Header 포함 전송 - HttpEntity body = new HttpEntity<>(jsonBody, headers); - - RestTemplate restTemplate = new RestTemplate(); - restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory()); - SmsNaverRes smsNaverRes = restTemplate.postForObject(new URI("https://sens.apigw.ntruss.com/sms/v2/services/"+this.serviceId+"/messages"), body, SmsNaverRes.class); - - // 실페 시, Error 던지기 - if(!smsNaverRes.getStatusCode().equals("202")) { - throw new RuntimeException("문자 전송에 실패했습니다."); - } - - // 성공 시, 임시 비밀번호 반환 - return temporaryPassword; - } - - public Void sendInvitation(String name, String lectureName, PostInviteReq postInviteReq, Boolean type) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException, URISyntaxException { - // API 요청 Header, Body 구성 - Long time = System.currentTimeMillis(); - List smsMessageDtoList = new ArrayList<>(); - - if(type.equals(Boolean.TRUE)) { - String regexOfEmojis = "[\uD83C-\uDBFF\uDC00-\uDFFF]+"; - //String content = "[과릿] " + name + " 선생님으로부터 " + lectureName + " 클래스 초대가 도착했습니다." + "\n" + "아래 링크를 통해 앱 설치 및 회원가입을 통해 수업에 참여해보세요!" + "\n" + "\n" + "안드로이드: " + "https://bit.ly/gwarit-android" + "\n" + "\n" + "애플: " + "https://bit.ly/gwarit-apple"; - String content = "[과릿] 선생님으로부터 클래스 초대가 도착했습니다." + "\n" + "아래 링크를 통해 앱 설치 및 회원가입을 통해 수업에 참여해보세요!" + "\n" + "\n" + "안드로이드: " + "https://bit.ly/gwarit-android" + "\n" + "\n" + "애플: " + "https://bit.ly/gwarit-apple"; - smsMessageDtoList.add(new SmsMessageDto(postInviteReq.getPhone(), content)); - } - if(type.equals(Boolean.FALSE)) { - String regexOfEmojis = "[\uD83C-\uDBFF\uDC00-\uDFFF]+"; - String content = "[과릿] 선생님으로부터 클래스 초대가 도착했습니다." + "\n" + "아래 링크를 클릭 후 앱 열기를 통해 수업에 참여해보세요!" + "\n" + "\n" + "안드로이드: " + "https://bit.ly/gwarit-android" + "\n" + "\n" + "애플: " + "https://bit.ly/gwarit-apple"; - smsMessageDtoList.add(new SmsMessageDto(postInviteReq.getPhone(), content)); - } - - ObjectMapper objectMapper = new ObjectMapper(); - String jsonBody = objectMapper.writeValueAsString(new SmsNaverReq("MMS", this.senderPhone, name, smsMessageDtoList)); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("x-ncp-apigw-timestamp", time.toString()); - headers.set("x-ncp-iam-access-key", this.accessKey); - String sig = makeSignature(time); // 암호화 - headers.set("x-ncp-apigw-signature-v2", sig); - - // Header 포함 전송 - HttpEntity body = new HttpEntity<>(jsonBody, headers); - - RestTemplate restTemplate = new RestTemplate(); - restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory()); - SmsNaverRes smsNaverRes = restTemplate.postForObject(new URI("https://sens.apigw.ntruss.com/sms/v2/services/"+this.serviceId+"/messages"), body, SmsNaverRes.class); - - // 실페 시, Error 던지기 - if(!smsNaverRes.getStatusCode().equals("202")) { - throw new RuntimeException("문자 전송에 실패했습니다."); - } - - return null; - } - - public String makeSignature(Long time) throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException { - String space = " "; // one space - String newLine = "\n"; // new line - String method = "POST"; // method - String url = "/sms/v2/services/"+ this.serviceId +"/messages"; // url (include query string) - String timestamp = time.toString(); // current timestamp (epoch) - String accessKey = this.accessKey; // access key id (from portal or Sub Account) - String secretKey = this.secretKey; - - String message = new StringBuilder() - .append(method) - .append(space) - .append(url) - .append(newLine) - .append(timestamp) - .append(newLine) - .append(accessKey) - .toString(); - - SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256"); - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(signingKey); - - byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8")); - String encodeBase64String = Base64.encodeBase64String(rawHmac); - - return encodeBase64String; - } -} diff --git a/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsMessageDto.java b/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsMessageDto.java deleted file mode 100644 index dcf978f1..00000000 --- a/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsMessageDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.selfrunner.gwalit.global.util.sms.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class SmsMessageDto { - private String to; - private String content; -} diff --git a/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsNaverReq.java b/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsNaverReq.java deleted file mode 100644 index 251511a0..00000000 --- a/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsNaverReq.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.selfrunner.gwalit.global.util.sms.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class SmsNaverReq { - private String type; - private String from; - private String Content; - private List messages; -} diff --git a/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsNaverRes.java b/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsNaverRes.java deleted file mode 100644 index 6ef57c45..00000000 --- a/src/main/java/com/selfrunner/gwalit/global/util/sms/dto/SmsNaverRes.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.selfrunner.gwalit.global.util.sms.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class SmsNaverRes { - - private String statusCode; - - private String statusName; -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fb77b364..39da5404 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,6 +10,10 @@ spring: username: ENC(23VtkEODP7NFAlIXh6iu3w==) url: ENC(xcpdNBbgA3cWXKlrjVOVHiQCCIWuZWy7JKD5qIxMnkBLZsDV2UOzYFr57bZZhEXj) password: ENC(Z3bshepqUUYRmo/xDJI/tsLRQ54VFjbS) +# url: ENC(cjE9nt26Kyi+TPxF8pPD0RXLenxNaoiIVGzsUxPa3xSwYIxY387damK4Zf7xN8J/p9XO2QWE2lVdkb3LjQSuapA0PEXLPtGf7uHKND0pOultghs/78YfaKv37bR3I8uA) +# username: ENC(WEGH/622uWGO/MqSOC6Uw7Vcbb+Ivy+7) +# password: ENC(11L+R8sQN5HBKnXMxFpfAm55jSn+gekH) + redis: host: ENC(w9qDnaEabKQ4NbweCUEcxoMp+an+TBDU) port: 6378