diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7eb13857..98d0796a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,18 +9,16 @@ name: BackEnd - CI/CD(build) on: pull_request: - branches: [ "main","develop" ] + branches: [ "develop", "release/v**", "main" ] push: - branches: [ "develop" ] + branches: [ "develop", "release/v**" ] permissions: contents: read jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v3 # JDK를 17 버전으로 셋팅한다. @@ -42,7 +40,7 @@ jobs: ${{ runner.os }}-gradle- # 프로젝트 저장소에 업로드하면 안되는 설정 파일들을 만들어줍니다. - - name: Make application.yml + - name: Make application.yml & Keys run: | # src/main/resources 경로를 이동 cd ./src/main/resources @@ -51,8 +49,15 @@ jobs: touch ./application-dev.yml # 등록해둔 Github Secrets의 내용을 이용해서 yml 파일의 내용을 써줍니다. echo "$APPLICATION_DEV" > ./application-dev.yml + + # key 폴더 만들고 cloudfront 키 넣기 + mkdir key + cd key + touch ./private_key.pem + echo "$CLOUDFRONT_KEY" > ./private_key.pem env: APPLICATION_DEV: ${{ secrets.APPLICATION_DEV }} + CLOUDFRONT_KEY: ${{ secrets.CLOUD_FRONT_KEY }} shell: bash - name: Gradle 권한 부여 @@ -63,6 +68,8 @@ jobs: - name: Discord 알림 봇 uses: sarisia/actions-status-discord@v1 - if: always() + if: ${{ failure() }} with: - webhook: ${{ secrets.DISCORD_WEBHOOK }} \ No newline at end of file + title: ❗️ Backend CI failed ❗️ + webhook: ${{ secrets.DISCORD_WEBHOOK }} + color: FFFF00 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-dev.yml similarity index 52% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-dev.yml index a17a3e81..acfb5c18 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy-dev.yml @@ -9,28 +9,33 @@ name: BackEnd - CI/CD(deploy) on: pull_request: - types: [ closed ] branches: [ "develop" ] + types: [ closed ] + permissions: contents: read jobs: - deploy: + deploy-dev: if: github.event.pull_request.merged == true runs-on: ubuntu-latest - + environment: develop + env: + DEPLOYMENT_GROUP_NAME: meeteam-dev + S3_BUCKET_DIR_NAME: dev steps: - - uses: actions/checkout@v3 + - name: ✅ Checkout branch + uses: actions/checkout@v3 # JDK를 17 버전으로 셋팅한다. - - name: Set up JDK 17 + - name: ⚙️ Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' # Gradle을 캐싱한다 -> 빌드 속도가 증가하는 효과가 있다. - - name: Gradle 캐싱 + - name: ✅ Gradle 캐싱 uses: actions/cache@v3 with: path: | @@ -41,69 +46,86 @@ jobs: ${{ runner.os }}-gradle- # 프로젝트 저장소에 업로드하면 안되는 설정 파일들을 만들어줍니다. - - name: Make application.yml & ApproveMail.html + - name: 🗂️ Make config run: | # src/main/resources 경로 이동 - cd ./src/main/resources + cd ./src/main/resources # yml 파일 생성 - touch ./application-dev.yml - echo "$APPLICATION_DEV" > ./application-dev.yml + touch ./application-dev.yml + echo "$APPLICATION_DEV" > ./application-dev.yml - # 메일 관련된 html 파일 생성 + # 키 파일 생성 + touch ./private-key.pem + echo "$CLOUD_FRONT_KEY" > ./private-key.pem + + # 폴더 생성 mkdir templates + + # 메일 관련된 html 파일 생성 cd ./templates touch ./ApproveMail.html echo "$MAIL_APPROVE_TEMPLATE" > ./ApproveMail.html + + touch ./UniversityAuthMail.html + echo "$MAIL_VERIFY_TEMPLATE" > ./UniversityAuthMail.html + + touch ./ApplicationNotificationMail.html + echo "$MAIL_APPLICATION_NOTIFICATION_TEMPLATE" > ./ApplicationNotificationMail.html + env: APPLICATION_DEV: ${{ secrets.APPLICATION_DEV }} MAIL_APPROVE_TEMPLATE: ${{ secrets.MAIL_APPROVE_TEMPLATE }} + MAIL_VERIFY_TEMPLATE: ${{ secrets.MAIL_VERIFY_TEMPLATE }} + MAIL_APPLICATION_NOTIFICATION_TEMPLATE: ${{ secrets.MAIL_APPLICATION_NOTIFICATION_TEMPLATE }} + CLOUD_FRONT_KEY: ${{ secrets.CLOUD_FRONT_KEY }} shell: bash - - name: Gradle 권한 부여 + - name: ⚙️ Gradle 권한 부여 run: chmod +x gradlew - - name: Gradle로 빌드 실행 + - name: ⚙️ Gradle로 빌드 실행 run: ./gradlew bootjar - - - # 배포에 필요한 여러 설정 파일과 프로젝트 빌드파일을 zip 파일로 모아줍니다. - - name: zip file 생성 + # 배포에 필요한 여러 설정 파일과 프로젝트 빌드파일을 zip 파일로 모아준다. + - name: 📦 zip file 생성 run: | mkdir deploy - cp ./docker/docker-compose.blue.yml ./deploy/ - cp ./docker/docker-compose.green.yml ./deploy/ + mkdir deploy/dev + cp ./docker/dev/docker-compose.dev.yml ./deploy/dev + cp ./docker/dev/Dockerfile ./deploy/dev + cp ./src/main/resources/private-key.pem ./deploy/dev + cp ./build/libs/*.jar ./deploy/dev cp ./appspec.yml ./deploy/ - cp ./docker/Dockerfile ./deploy/ - cp ./scripts/*.sh ./deploy/ - cp ./build/libs/*.jar ./deploy/ - zip -r -qq -j ./spring-build.zip ./deploy + cp ./scripts/deploy.sh ./deploy/ + zip -r -qq ./spring-app.zip ./deploy - # AWS에 연결해줍니다. - - name: AWS 연결 + # AWS에 연결해준다. + - name: 🌎 AWS 연결 uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - # S3에 프로젝트를 업로드 합니다. - - name: S3에 프로젝트 업로드 + # S3에 프로젝트를 업로드 한다. + - name: 🚛 S3에 프로젝트 업로드 run: | aws s3 cp \ --region ap-northeast-2 \ - ./spring-build.zip s3://meeteam-backend-bucket + ./spring-app.zip s3://${{ secrets.S3_BUCKET_NAME }}/${{ env.S3_BUCKET_DIR_NAME }}/spring-app.zip - # CodeDelploy에 배포를 요청합니다. - - name: Code Deploy 배포 요청 + # CodeDelploy에 배포를 요청한다. + - name: 🚀 Code Deploy 배포 요청 run: aws deploy create-deployment --application-name meeteam-app --deployment-config-name CodeDeployDefault.OneAtATime - --deployment-group-name meeteam-app - --s3-location bucket=meeteam-backend-bucket,bundleType=zip,key=spring-build.zip + --deployment-group-name ${{ env.DEPLOYMENT_GROUP_NAME }} + --s3-location bucket=${{ secrets.S3_BUCKET_NAME }},bundleType=zip,key=${{ env.S3_BUCKET_DIR_NAME }}/spring-app.zip - name: Discord 알림 봇 uses: sarisia/actions-status-discord@v1 - if: always() + if: ${{ failure() }} with: - webhook: ${{ secrets.DISCORD_WEBHOOK }} \ No newline at end of file + title: ❗️ Backend CD failed ❗️ + webhook: ${{ secrets.DISCORD_WEBHOOK }} + color: FF0000 \ No newline at end of file diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..935108d8 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,131 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: BackEnd - CI/CD(deploy) + +on: + pull_request: + branches: [ "main" ] + types: [ closed ] + + +permissions: + contents: read + +jobs: + deploy-prod: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + environment: production + env: + DEPLOYMENT_GROUP_NAME: meeteam-app + S3_BUCKET_DIR_NAME: prod + steps: + - name: ✅ Checkout branch + uses: actions/checkout@v3 + # JDK를 17 버전으로 셋팅한다. + - name: ⚙️ Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # Gradle을 캐싱한다 -> 빌드 속도가 증가하는 효과가 있다. + - name: ✅ Gradle 캐싱 + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # 프로젝트 저장소에 업로드하면 안되는 설정 파일들을 만들어줍니다. + - name: 🗂️ Make config + run: | + # src/main/resources 경로 이동 + cd ./src/main/resources + + # yml 파일 생성 + touch ./application-prod.yml + echo "$APPLICATION_PROD" > ./application-prod.yml + + # 키 파일 생성 + touch ./private-key.pem + echo "$CLOUD_FRONT_KEY" > ./private-key.pem + + # 폴더 생성 + mkdir templates + + # 메일 관련된 html 파일 생성 + cd ./templates + touch ./ApproveMail.html + echo "$MAIL_APPROVE_TEMPLATE" > ./ApproveMail.html + + touch ./UniversityAuthMail.html + echo "$MAIL_VERIFY_TEMPLATE" > ./UniversityAuthMail.html + + touch ./ApplicationNotificationMail.html + echo "$MAIL_APPLICATION_NOTIFICATION_TEMPLATE" > ./ApplicationNotificationMail.html + + env: + APPLICATION_PROD: ${{ secrets.APPLICATION_PROD }} + MAIL_APPROVE_TEMPLATE: ${{ secrets.MAIL_APPROVE_TEMPLATE }} + MAIL_VERIFY_TEMPLATE: ${{ secrets.MAIL_VERIFY_TEMPLATE }} + MAIL_APPLICATION_NOTIFICATION_TEMPLATE: ${{ secrets.MAIL_APPLICATION_NOTIFICATION_TEMPLATE }} + CLOUD_FRONT_KEY: ${{ secrets.CLOUD_FRONT_KEY }} + shell: bash + + - name: ⚙️ Gradle 권한 부여 + run: chmod +x gradlew + + - name: ⚙️ Gradle로 빌드 실행 + run: ./gradlew bootjar + # 배포에 필요한 여러 설정 파일과 프로젝트 빌드파일을 zip 파일로 모아준다. + - name: 📦 zip file 생성 + run: | + mkdir deploy + mkdir deploy/prod + cp ./docker/prod/docker-compose.prod.yml ./deploy/prod + cp ./docker/prod/Dockerfile ./deploy/prod + cp ./src/main/resources/private-key.pem ./deploy/prod + cp ./build/libs/*.jar ./deploy/prod + cp ./scripts/deploy.sh ./deploy/ + cp ./appspec.yml ./deploy/ + zip -r -qq ./spring-app.zip ./deploy + + + # AWS에 연결해준다. + - name: 🌎 AWS 연결 + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + # S3에 프로젝트를 업로드 한다. + - name: 🚛 S3에 프로젝트 업로드 + run: | + aws s3 cp \ + --region ap-northeast-2 \ + ./spring-app.zip s3://${{ secrets.S3_BUCKET_NAME }}/${{ env.S3_BUCKET_DIR_NAME }}/spring-app.zip + + # CodeDelploy에 배포를 요청한다. + - name: 🚀 Code Deploy 배포 요청 + run: aws deploy create-deployment --application-name meeteam-app + --deployment-config-name CodeDeployDefault.OneAtATime + --deployment-group-name ${{ env.DEPLOYMENT_GROUP_NAME }} + --s3-location bucket=${{ secrets.S3_BUCKET_NAME }},bundleType=zip,key=${{ env.S3_BUCKET_DIR_NAME }}/spring-app.zip + + - name: Discord 알림 봇 + uses: sarisia/actions-status-discord@v1 + if: ${{ failure() }} + with: + title: ❗️ Backend CD failed ❗️ + webhook: ${{ secrets.DISCORD_WEBHOOK }} + color: FF0000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 41bc8966..1646b5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -177,8 +177,10 @@ replay_pid* # End of https://www.toptal.com/developers/gitignore/api/intellij,java -*.yml +src/**/*.yml *.sql *.html src/main/generated -.gradle \ No newline at end of file +.gradle + +key \ No newline at end of file diff --git a/appspec.yml b/appspec.yml index cd79ac28..43d0fc97 100644 --- a/appspec.yml +++ b/appspec.yml @@ -11,6 +11,7 @@ files: destination: /home/ubuntu/app # 대상 경로에 이미 파일이 존재하는 경우, 덮어쓰기를 허용할지 여부 overwrite: yes +file_exists_behavior: OVERWRITE # 파일 및 디렉토리 권한에 관련된 설정 permissions: @@ -30,6 +31,6 @@ hooks: # 실행할 스크립트 또는 명령의 위치 - location: deploy.sh # 스크립트 또는 명령 실행의 제한 시간을 설정 - timeout: 60 + timeout: 180 # CodeDeploy 중 실행되는 스크립트 또는 명령을 실행할 사용자를 지정 runas: ubuntu \ No newline at end of file diff --git a/build.gradle b/build.gradle index a633bfd9..3c42d891 100644 --- a/build.gradle +++ b/build.gradle @@ -105,6 +105,21 @@ dependencies { // discord web hook implementation('com.github.napstr:logback-discord-appender:1.0.0') + + // aop + implementation 'org.springframework.boot:spring-boot-starter-aop' + + // ssh 터널링 세팅 + implementation 'com.github.mwiede:jsch:0.2.17' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + // actuator 설정 + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // cloudfront + implementation("software.amazon.awssdk:bom:2.21.1") + implementation 'software.amazon.awssdk:cloudfront:2.22.3' + } tasks.named('test') { diff --git a/docker/Dockerfile b/docker/dev/Dockerfile similarity index 68% rename from docker/Dockerfile rename to docker/dev/Dockerfile index deaa8d53..a71d1a79 100644 --- a/docker/Dockerfile +++ b/docker/dev/Dockerfile @@ -4,5 +4,7 @@ FROM openjdk:17 ARG JAR_FILE=*.jar ### JAR_FILE 경로에 해당하는 파일을 Docker 이미지 내부로 복사한다. COPY ${JAR_FILE} meeteam.jar +### CloudFront Private Key 복사 +COPY private-key.pem /app/private-key.pem ### Docker 컨테이너가 시작될 때 실행할 명령을 지정한다. -ENTRYPOINT ["java","-jar","-Dspring.profiles.active=dev","meeteam.jar"] +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=dev","-Duser.timezone=Asia/Seoul","meeteam.jar"] diff --git a/docker/dev/docker-compose.dev.yml b/docker/dev/docker-compose.dev.yml new file mode 100644 index 00000000..123e797e --- /dev/null +++ b/docker/dev/docker-compose.dev.yml @@ -0,0 +1,26 @@ +version: '3.8' + +networks: + development_network: + driver: bridge + +services: + redis: + image: "redis:alpine" + container_name: redis-dev + networks: + - development_network + expose: + - "6379" + + spring-app: + build: + context: . + dockerfile: Dockerfile + container_name: dev-spring-app + environment: + - SPRING_PROFILES_ACTIVE=dev + networks: + - development_network + ports: + - "1821:8080" diff --git a/docker/docker-compose.blue.yml b/docker/docker-compose.blue.yml deleted file mode 100644 index f30844d1..00000000 --- a/docker/docker-compose.blue.yml +++ /dev/null @@ -1,14 +0,0 @@ -#blue -version: '3' -services: - # 서비스의 이름 - backend: - # 현재 디렉토리에서의 Dockerfile을 사용하여 Docker 이미지를 빌드 - build: . - # 호스트의 8081 포트와 컨테이너의 80 포트를 매핑 - ports: - - "8081:8080" - # 컨테이너의 이름 - container_name: spring-blue - extra_hosts: - - "host.docker.internal:host-gateway" \ No newline at end of file diff --git a/docker/docker-compose.green.yml b/docker/docker-compose.green.yml deleted file mode 100644 index b1d8680f..00000000 --- a/docker/docker-compose.green.yml +++ /dev/null @@ -1,10 +0,0 @@ -#green -version: '3' -services: - backend: - build: . - ports: - - "8082:8080" - container_name: spring-green - extra_hosts: - - "host.docker.internal:host-gateway" \ No newline at end of file diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile new file mode 100644 index 00000000..0687d2e9 --- /dev/null +++ b/docker/prod/Dockerfile @@ -0,0 +1,10 @@ +### Docker 이미지를 생성할 때 기반이 되는 베이스 이미지를 설정한다. +FROM openjdk:17 +### Dockerfile 내에서 사용할 변수 JAR_FILE을 정의한다. +ARG JAR_FILE=*.jar +### JAR_FILE 경로에 해당하는 파일을 Docker 이미지 내부로 복사한다. +COPY ${JAR_FILE} meeteam.jar +### CloudFront Private Key 복사 +COPY private-key.pem /app/private-key.pem +### Docker 컨테이너가 시작될 때 실행할 명령을 지정한다. +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","-Duser.timezone=Asia/Seoul","meeteam.jar"] diff --git a/docker/prod/docker-compose.prod.yml b/docker/prod/docker-compose.prod.yml new file mode 100644 index 00000000..7efe4d6c --- /dev/null +++ b/docker/prod/docker-compose.prod.yml @@ -0,0 +1,44 @@ +version: '3.8' + +networks: + production_network: + name: production_network + driver: bridge + +services: + redis: + image: "redis:alpine" + container_name: redis + restart: always + networks: + - production_network + expose: + - "6379" + volumes: + - redis_data:/data + + spring-app-blue: + build: . + container_name: prod-blue + networks: + - production_network + ports: + - "8081:8080" + volumes: + - /home/ubuntu/app/log:/log + depends_on: + - redis + + spring-app-green: + build: . + container_name: prod-green + networks: + - production_network + ports: + - "8082:8080" + volumes: + - /home/ubuntu/app/log:/log + depends_on: + - redis +volumes: + redis_data: \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 4e99fdc9..a0888bbd 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,58 +1,122 @@ #!/bin/bash -# 작업 디렉토리를 /home/ubuntu/app으로 변경 -cd /home/ubuntu/app - -# 환경변수 DOCKER_APP_NAME을 spring으로 설정 -DOCKER_APP_NAME=spring - - -# 실행중인 blue가 있는지 확인 -# 프로젝트의 실행 중인 컨테이너를 확인하고, 해당 컨테이너가 실행 중인지 여부를 EXIST_BLUE 변수에 저장 -EXIST_BLUE=$(sudo docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps | grep Up) - -# 배포 시작한 날짜와 시간을 기록 -echo "배포 시작일자 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - -# green이 실행중이면 blue up -# EXIST_BLUE 변수가 비어있는지 확인 -if [ -z "$EXIST_BLUE" ]; then - - # 로그 파일(/home/ubuntu/deploy.log)에 "blue up - blue 배포 : port:8081"이라는 내용을 추가 - echo "blue 배포 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - - # docker-compose.blue.yml 파일을 사용하여 spring-blue 프로젝트의 컨테이너를 빌드하고 실행 - sudo docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml up -d --build - - # 30초 동안 대기 - sleep 30 - - # /home/ubuntu/deploy.log: 로그 파일에 "green 중단 시작"이라는 내용을 추가 - echo "green 중단 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - - # docker-compose.green.yml 파일을 사용하여 spring-green 프로젝트의 컨테이너를 중지 - sudo docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml down - - # 사용하지 않는 이미지 삭제 - sudo docker image prune -af - - echo "green 중단 완료 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - -# blue가 실행중이면 green up +DEPLOYMENT_ID=$DEPLOYMENT_ID +# 배포 ID가 제공되지 않으면 오류를 출력하고 종료 +if [ -z "$DEPLOYMENT_ID" ]; then + echo "Deployment ID is not set. Please ensure the deployment ID is provided." + exit 1 +fi +# 배포 그룹 이름 추출 +DEPLOYMENT_GROUP_NAME=$(aws deploy get-deployment --deployment-id $DEPLOYMENT_ID --query 'deploymentInfo.deploymentGroupName' --output text) +echo $DEPLOYMENT_GROUP_NAME + +# 만약 배포가 prod 라면 +if [ "$DEPLOYMENT_GROUP_NAME" = "meeteam-app" ]; then + # 작업 디렉토리를 /home/ubuntu/app/prod으로 변경 + cd /home/ubuntu/app/prod + + # 환경변수 DOCKER_APP_NAME을 spring-app으로 설정 + DOCKER_APP_NAME=spring-app + + # 실행중인 blue가 있는지 확인 + # 프로젝트의 실행 중인 컨테이너를 확인하고, 해당 컨테이너가 실행 중인지 여부를 EXIST_BLUE 변수에 저장 + EXIST_REDIS=$(sudo docker ps --filter "name=redis" --filter "status=running") + EXIST_BLUE=$(sudo docker ps --filter "ancestor=${DOCKER_APP_NAME}-blue" --filter "status=running") + + # 배포 시작한 날짜와 시간을 기록 + echo "배포 시작일자 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + + # green이 실행중이면 blue up + # EXIST_BLUE 변수가 비어있는지 확인 + if [ -z "$EXIST_REDIS" ]; then + # 로그 파일(/home/ubuntu/deploy.log)에 "blue up - blue 배포 : port:8081"이라는 내용을 추가 + echo "blue 배포 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + + # docker-compose.prod.yml 파일을 사용하여 prod-blue, redis 서비스를 빌드하고 실행 + sudo docker-compose -f docker-compose.prod.yml up redis ${DOCKER_APP_NAME}-blue -d --build + + while [ 1 = 1 ]; do + echo ">>> spring blue health check ..." + sleep 3 + REQUEST_SPRING=$(curl 127.0.0.1:8081/actuator/health) + if [ -n "$REQUEST_SPRING" ]; then + echo ">>> spring blue health check success !" + break; + fi + done; + + elif [ -z "$EXIST_BLUE" ]; then + + # 로그 파일(/home/ubuntu/deploy.log)에 "blue up - blue 배포 : port:8081"이라는 내용을 추가 + echo "blue 배포 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + + # docker-compose.prod.yml 파일을 사용하여 prod-blue 서비스를 빌드하고 실행 + sudo docker-compose -f docker-compose.prod.yml up ${DOCKER_APP_NAME}-blue -d --build + + while [ 1 = 1 ]; do + echo ">>> spring blue health check ..." + sleep 3 + REQUEST_SPRING=$(curl 127.0.0.1:8081/actuator/health) + if [ -n "$REQUEST_SPRING" ]; then + echo ">>> spring blue health check success !" + break; + fi + done; + + # /home/ubuntu/deploy.log: 로그 파일에 "green 중단 시작"이라는 내용을 추가 + echo "green 중단 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + + # docker-compose.prod.yml 파일을 사용하여 spring-app-green 서비스 중지 + sudo docker-compose -f docker-compose.prod.yml down ${DOCKER_APP_NAME}-green + + # 사용하지 않는 이미지 삭제 + sudo docker image prune -af + + echo "green 중단 완료 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + + # blue가 실행중이면 green up + else + echo "green 배포 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + sudo docker-compose -f docker-compose.prod.yml up ${DOCKER_APP_NAME}-green -d --build + + while [ 1 = 1 ]; do + echo ">>> spring green health check ..." + sleep 3 + REQUEST_SPRING=$(curl 127.0.0.1:8082/actuator/health) + if [ -n "$REQUEST_SPRING" ]; then + echo ">>> spring green health check success !" + break; + fi + done; + + echo "blue 중단 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + # docker-compose.prod.yml 파일을 사용하여 spring-app-blue 서비스 중지 + sudo docker-compose -f docker-compose.prod.yml down ${DOCKER_APP_NAME}-blue + + sudo docker image prune -af + + echo "blue 중단 완료 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + + fi + echo "배포 종료 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log + + echo "===================== 배포 완료 =====================" >> /home/ubuntu/deploy.log + echo >> /home/ubuntu/deploy.log +#만약 배포가 개발이라면 else - echo "green 배포 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - sudo docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml up -d --build - - sleep 30 - - echo "blue 중단 시작 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - sudo docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml down + cd /home/ubuntu/app/dev + # 현재 실행 중인 컨테이너를 중지하고 제거합니다 + docker-compose -f docker-compose.dev.yml down + # 새 이미지를 빌드하고 컨테이너를 백그라운드에서 실행합니다 + docker-compose -f docker-compose.dev.yml up -d --build + while [ 1 = 1 ]; do + echo ">>> spring green health check ..." + sleep 3 + REQUEST_SPRING=$(curl 127.0.0.1:1821/actuator/health) + if [ -n "$REQUEST_SPRING" ]; then + echo ">>> spring green health check success !" + break; + fi + done; sudo docker image prune -af - - echo "blue 중단 완료 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - -fi - echo "배포 종료 : $(date +%Y)-$(date +%m)-$(date +%d) $(date +%H):$(date +%M):$(date +%S)" >> /home/ubuntu/deploy.log - - echo "===================== 배포 완료 =====================" >> /home/ubuntu/deploy.log - echo >> /home/ubuntu/deploy.log \ No newline at end of file +fi \ No newline at end of file diff --git a/src/main/java/synk/meeteam/MeeteamApplication.java b/src/main/java/synk/meeteam/MeeteamApplication.java index 385d1bc2..604e9ce3 100644 --- a/src/main/java/synk/meeteam/MeeteamApplication.java +++ b/src/main/java/synk/meeteam/MeeteamApplication.java @@ -1,20 +1,31 @@ package synk.meeteam; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.annotation.PostConstruct; import jakarta.persistence.EntityManager; +import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling +@EnableAspectJAutoProxy public class MeeteamApplication { public static void main(String[] args) { SpringApplication.run(MeeteamApplication.class, args); } + @PostConstruct + void started() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + @Bean JPAQueryFactory jpaQueryFactory(EntityManager em) { return new JPAQueryFactory(em); diff --git a/src/main/java/synk/meeteam/domain/auth/api/AuthController.java b/src/main/java/synk/meeteam/domain/auth/api/AuthController.java index d21246dd..00b61e0d 100644 --- a/src/main/java/synk/meeteam/domain/auth/api/AuthController.java +++ b/src/main/java/synk/meeteam/domain/auth/api/AuthController.java @@ -21,7 +21,7 @@ import synk.meeteam.domain.auth.dto.response.AuthUserResponseMapper; import synk.meeteam.domain.auth.dto.response.ReissueUserResponseDto; import synk.meeteam.domain.auth.service.AuthServiceProvider; -import synk.meeteam.domain.auth.service.vo.AuthUserVo; +import synk.meeteam.domain.auth.service.vo.AuthUserVO; import synk.meeteam.domain.common.university.service.UniversityService; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.domain.user.user.entity.UserVO; @@ -55,17 +55,16 @@ public class AuthController implements AuthApi { public ResponseEntity login( @RequestBody @Valid final AuthUserRequestDto requestDto) { - AuthUserVo vo = authServiceProvider.getAuthService(requestDto.platformType()) + AuthUserVO vo = authServiceProvider.getAuthService(requestDto.platformType()) .saveUserOrLogin(requestDto.authorizationCode(), requestDto); if (vo.authority() == Authority.GUEST) { - AuthUserResponseDto.create responseDTO = authUserResponseMapper.ofCreate(vo.authType(), vo.authority(), - vo.platformId()); + AuthUserResponseDto.create responseDTO = authUserResponseMapper.ofCreate(vo.authType(), vo.authority(), vo.platformId()); return ResponseEntity.ok(responseDTO); } AuthUserResponseDto.login responseDTO = jwtService.issueToken(vo); - return ResponseEntity.ok(responseDTO); + return ResponseEntity.ok().body(responseDTO); } @Override @@ -75,7 +74,7 @@ public ResponseEntity requestEmailVerify( ) { String email = universityService.getEmail(requestDto.universityId(), requestDto.emailId()); authServiceProvider.getAuthService(requestDto.platformType()).updateUniversityInfo(requestDto, email); - mailService.sendMail(requestDto.platformId(), email); + mailService.sendVerifyMail(requestDto.platformId(), email); return ResponseEntity.ok().build(); } @@ -89,8 +88,9 @@ public ResponseEntity signUp( User user = authServiceProvider.getAuthService(userVO.getPlatformType()) .createSocialUser(userVO, requestDto.nickname()); - AuthUserVo vo = AuthUserVo.of(user, user.getPlatformType(), user.getAuthority(), AuthType.SIGN_UP); + AuthUserVO vo = AuthUserVO.of(user, user.getPlatformType(), user.getAuthority(), AuthType.SIGN_UP); AuthUserResponseDto.login responseDTO = jwtService.issueToken(vo); + mailService.deleteTemporaryUser(requestDto.emailCode()); return ResponseEntity.status(HttpStatus.CREATED).body(responseDTO); } diff --git a/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseDto.java b/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseDto.java index aa7af925..76b6e5c5 100644 --- a/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseDto.java +++ b/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseDto.java @@ -30,17 +30,20 @@ public static class login extends InnerParent { private String nickname; @Schema(description = "유저 프로필 사진", example = "url 형태") private String imageUrl; + @Schema(description = "유저 대학교", example = "광운대학교") + private String university; @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1N...(액세스 토큰)") private String accessToken; @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1N...(리프레시 토큰)") private String refreshToken; public login(AuthType authType, Authority authority, String userId, String nickname, String imageUrl, - String accessToken, String refreshToken) { + String universityName, String accessToken, String refreshToken) { super(authType, authority); this.userId = userId; this.nickname = nickname; this.imageUrl = imageUrl; + this.university = universityName; this.accessToken = accessToken; this.refreshToken = refreshToken; } diff --git a/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseMapper.java b/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseMapper.java index e299ec40..b4323928 100644 --- a/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseMapper.java +++ b/src/main/java/synk/meeteam/domain/auth/dto/response/AuthUserResponseMapper.java @@ -9,7 +9,7 @@ public interface AuthUserResponseMapper { AuthUserResponseDto.create ofCreate(AuthType authType, Authority authority, String platformId); AuthUserResponseDto.login ofLogin(AuthType authType, Authority authority, String userId, String nickname, - String pictureUrl, String accessToken, + String imageUrl, String universityName, String accessToken, String refreshToken); } diff --git a/src/main/java/synk/meeteam/domain/auth/dto/response/ReissueUserResponseDto.java b/src/main/java/synk/meeteam/domain/auth/dto/response/ReissueUserResponseDto.java index a381abd1..846d8502 100644 --- a/src/main/java/synk/meeteam/domain/auth/dto/response/ReissueUserResponseDto.java +++ b/src/main/java/synk/meeteam/domain/auth/dto/response/ReissueUserResponseDto.java @@ -4,7 +4,7 @@ @Schema(name = "ReissueUserResponseDto", description = "토큰 재발급 요청 Dto") public record ReissueUserResponseDto( - @Schema(description = "플랫폼 Id", example = "Di7lChMGxjZVTai6d76Ho1YLDU_xL8tl1CfdPMV5SQM") + @Schema(description = "user Id", example = "40aVE421DSwR63xfKf6vxA") String platformId, @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInBsYXRmb3JtSWQiOiJEaTdsQ2hNR3hqWlZUYWk2ZDc2SG8xWUxEVV94TDh0bDFDZmRQTVY1U1FNIiwicGxhdGZvcm1UeXBlIjoiTkFWRVIiLCJpYXQiOjE3MDYyODA1MjMsImV4cCI6MTgxNDI4MDUyM30.doPtAdLQMZ8NeuhRAOg7GNMBBtFZzPOOZp60HskGtZ0") String accessToken, diff --git a/src/main/java/synk/meeteam/domain/auth/exception/AuthExceptionType.java b/src/main/java/synk/meeteam/domain/auth/exception/AuthExceptionType.java index 1039b2f5..95a22e99 100644 --- a/src/main/java/synk/meeteam/domain/auth/exception/AuthExceptionType.java +++ b/src/main/java/synk/meeteam/domain/auth/exception/AuthExceptionType.java @@ -15,6 +15,8 @@ public enum AuthExceptionType implements ExceptionType { INVALID_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 액세스 토큰입니다."), INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 리프레시 토큰입니다."), INVALID_MAIL_SERVICE(HttpStatus.BAD_REQUEST, "메일 서비스를 이용할 수 없는 형식입니다."), + INVALID_ACCESS(HttpStatus.BAD_REQUEST, "올바르지 않는 요청입니다."), + ALREADY_REGISTER(HttpStatus.BAD_REQUEST, "이미 가입된 이메일입니다."), INVALID_VERIFY_MAIL(HttpStatus.BAD_REQUEST, "잘못된 이메일 코드 입니다."), INVALID_MAIL_REGEX(HttpStatus.BAD_REQUEST, "학교 도메인과 유저의 도메인이 다릅니다."), diff --git a/src/main/java/synk/meeteam/domain/auth/service/AuthService.java b/src/main/java/synk/meeteam/domain/auth/service/AuthService.java index a89cf120..fd114714 100644 --- a/src/main/java/synk/meeteam/domain/auth/service/AuthService.java +++ b/src/main/java/synk/meeteam/domain/auth/service/AuthService.java @@ -1,11 +1,15 @@ package synk.meeteam.domain.auth.service; +import static synk.meeteam.domain.auth.exception.AuthExceptionType.ALREADY_REGISTER; +import static synk.meeteam.domain.auth.exception.AuthExceptionType.INVALID_ACCESS; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.auth.dto.request.AuthUserRequestDto; import synk.meeteam.domain.auth.dto.request.VerifyEmailRequestDto; -import synk.meeteam.domain.auth.service.vo.AuthUserVo; +import synk.meeteam.domain.auth.exception.AuthException; +import synk.meeteam.domain.auth.service.vo.AuthUserVO; import synk.meeteam.domain.common.department.entity.Department; import synk.meeteam.domain.common.department.repository.DepartmentRepository; import synk.meeteam.domain.common.university.entity.University; @@ -26,9 +30,9 @@ public abstract class AuthService { private final DepartmentRepository departmentRepository; @Transactional - public abstract AuthUserVo saveUserOrLogin(String platformType, AuthUserRequestDto request); + public abstract AuthUserVO saveUserOrLogin(String platformType, AuthUserRequestDto request); - protected User getUser(PlatformType platformType, String platformId) { + protected User getUser(String platformId, PlatformType platformType) { return userRepository.findByPlatformIdAndPlatformType(platformId, platformType) .orElse(null); } @@ -45,9 +49,9 @@ private static UserVO createTempSocialUser(String email, String name, PlatformTy .build(); } - protected User saveTempUser(AuthUserRequestDto request, String email, String name, String id, + protected User saveTempUser(AuthUserRequestDto request, String email, String name, String platformId, String phoneNumber, String profileImgFileName) { - UserVO tempSocialUser = createTempSocialUser(email, name, request.platformType(), id, phoneNumber, + UserVO tempSocialUser = createTempSocialUser(email, name, request.platformType(), platformId, phoneNumber, profileImgFileName); redisUserRepository.save(tempSocialUser); @@ -56,7 +60,7 @@ protected User saveTempUser(AuthUserRequestDto request, String email, String nam .name(name) .phoneNumber(phoneNumber) .platformType(request.platformType()) - .platformId(id) + .platformId(platformId) .authority(Authority.GUEST) .profileImgFileName(profileImgFileName) .build(); @@ -64,6 +68,11 @@ protected User saveTempUser(AuthUserRequestDto request, String email, String nam @Transactional public void updateUniversityInfo(VerifyEmailRequestDto requestDTO, String email) { + User foundUser = userRepository.findByUniversityEmail(email).orElse(null); + if(foundUser != null){ + throw new AuthException(ALREADY_REGISTER); + } + UserVO userVO = redisUserRepository.findByPlatformIdOrElseThrowException(requestDTO.platformId()); userVO.updateUniversityInfo(requestDTO.universityId(), requestDTO.departmentId(), requestDTO.year(), email); @@ -72,7 +81,12 @@ public void updateUniversityInfo(VerifyEmailRequestDto requestDTO, String email) } @Transactional - public User createSocialUser(UserVO userVO, String nickName) { + public User createSocialUser(UserVO userVO, String nickname) { + // 닉네임 검사 + if(userRepository.findByNickname(nickname).isPresent()){ + throw new AuthException(INVALID_ACCESS); + } + University foundUniversity = universityRepository.findByIdOrElseThrowException( userVO.getUniversityId()); Department foundDepartment = departmentRepository.findByIdOrElseThrowException( @@ -81,7 +95,7 @@ public User createSocialUser(UserVO userVO, String nickName) { User newUser = User.builder() .universityEmail(userVO.getEmail()) .name(userVO.getName()) - .nickname(nickName) + .nickname(nickname) .phoneNumber(userVO.getPhoneNumber()) .admissionYear(userVO.getAdmissionYear()) .university(foundUniversity) diff --git a/src/main/java/synk/meeteam/domain/auth/service/vo/AuthUserVo.java b/src/main/java/synk/meeteam/domain/auth/service/vo/AuthUserVO.java similarity index 67% rename from src/main/java/synk/meeteam/domain/auth/service/vo/AuthUserVo.java rename to src/main/java/synk/meeteam/domain/auth/service/vo/AuthUserVO.java index 14606a4c..aa4710ff 100644 --- a/src/main/java/synk/meeteam/domain/auth/service/vo/AuthUserVo.java +++ b/src/main/java/synk/meeteam/domain/auth/service/vo/AuthUserVO.java @@ -8,14 +8,15 @@ import synk.meeteam.infra.oauth.service.vo.enums.AuthType; @Builder -public record AuthUserVo(Long userId, String email, String nickname, String pictureUrl, PlatformType platformType, +public record AuthUserVO(Long userId, String email, String universityName, String nickname, String profileImgUrl, PlatformType platformType, Authority authority, String platformId, String phoneNumber, AuthType authType) { - public static AuthUserVo of(User user, PlatformType platformType, Authority authority, AuthType authType) { - return AuthUserVo.builder() + public static AuthUserVO of(User user, PlatformType platformType, Authority authority, AuthType authType) { + return AuthUserVO.builder() .userId(user.getId()) .email(user.getUniversityEmail()) + .universityName(user.getUniversity() == null ? null : user.getUniversity().getName()) .nickname(user.getNickname()) - .pictureUrl(user.getProfileImgFileName()) + .profileImgUrl(user.getProfileImgFileName()) .platformType(platformType) .platformId(user.getPlatformId()) .authority(authority) diff --git a/src/main/java/synk/meeteam/domain/common/course/service/CourseService.java b/src/main/java/synk/meeteam/domain/common/course/service/CourseService.java index f567edf7..ac0a8385 100644 --- a/src/main/java/synk/meeteam/domain/common/course/service/CourseService.java +++ b/src/main/java/synk/meeteam/domain/common/course/service/CourseService.java @@ -14,11 +14,6 @@ public class CourseService { private final CourseRepository courseRepository; - @Transactional - public Course createCourse(Course courses) { - return courseRepository.save(courses); - } - @Transactional(readOnly = true) public List searchByKeyword(String keyword, long limit) { return courseRepository.findAllByKeywordAndTopLimit(keyword, limit); @@ -29,7 +24,7 @@ public Course getOrCreateCourse(String courseName, University university) { Course course = courseRepository.findByNameAndUniversity(courseName, university).orElse(null); if (course == null) { course = createCourse(courseName, university); - courseRepository.save(course); + return courseRepository.save(course); } return course; diff --git a/src/main/java/synk/meeteam/domain/common/course/service/ProfessorService.java b/src/main/java/synk/meeteam/domain/common/course/service/ProfessorService.java index d67370d8..3cc2d06a 100644 --- a/src/main/java/synk/meeteam/domain/common/course/service/ProfessorService.java +++ b/src/main/java/synk/meeteam/domain/common/course/service/ProfessorService.java @@ -14,11 +14,6 @@ public class ProfessorService { private final ProfessorRepository professorRepository; - @Transactional - public Professor createProfessor(Professor professor) { - return professorRepository.save(professor); - } - @Transactional(readOnly = true) public List searchByKeyword(String keyword, long limit) { return professorRepository.findAllByKeywordAndTopLimit(keyword, limit); @@ -29,7 +24,7 @@ public Professor getOrCreateProfessor(String professorName, University universit Professor professor = professorRepository.findByNameAndUniversity(professorName, university).orElse(null); if (professor == null) { professor = createProfessor(professorName, university); - professorRepository.save(professor); + return professorRepository.save(professor); } return professor; diff --git a/src/main/java/synk/meeteam/domain/common/department/entity/Department.java b/src/main/java/synk/meeteam/domain/common/department/entity/Department.java index b44172b6..b0b05328 100644 --- a/src/main/java/synk/meeteam/domain/common/department/entity/Department.java +++ b/src/main/java/synk/meeteam/domain/common/department/entity/Department.java @@ -32,6 +32,6 @@ public class Department { private University university; @NotNull - @Column(length = 20) + @Column(length = 70) private String name; } diff --git a/src/main/java/synk/meeteam/domain/common/role/repository/RoleRepositoryCustomImpl.java b/src/main/java/synk/meeteam/domain/common/role/repository/RoleRepositoryCustomImpl.java index 5fcaeab7..f72a1703 100644 --- a/src/main/java/synk/meeteam/domain/common/role/repository/RoleRepositoryCustomImpl.java +++ b/src/main/java/synk/meeteam/domain/common/role/repository/RoleRepositoryCustomImpl.java @@ -19,7 +19,7 @@ public List findAllByKeywordAndTopLimit(String keyword, long limit) { return queryFactory .select(new QRoleDto(role.id, role.name)) .from(role) - .where(role.name.startsWith(keyword)) + .where(role.name.contains(keyword)) .limit(limit) .orderBy(role.name.asc().nullsLast()) .fetch(); diff --git a/src/main/java/synk/meeteam/domain/common/skill/exception/SkillExceptionType.java b/src/main/java/synk/meeteam/domain/common/skill/exception/SkillExceptionType.java index 54295596..6fa2a9a5 100644 --- a/src/main/java/synk/meeteam/domain/common/skill/exception/SkillExceptionType.java +++ b/src/main/java/synk/meeteam/domain/common/skill/exception/SkillExceptionType.java @@ -18,11 +18,11 @@ public enum SkillExceptionType implements ExceptionType { @Override public HttpStatus httpStatus() { - return null; + return status; } @Override public String message() { - return null; + return message; } } diff --git a/src/main/java/synk/meeteam/domain/common/skill/repository/SkillRepositoryCustomImpl.java b/src/main/java/synk/meeteam/domain/common/skill/repository/SkillRepositoryCustomImpl.java index e8ae0f50..d71faa1d 100644 --- a/src/main/java/synk/meeteam/domain/common/skill/repository/SkillRepositoryCustomImpl.java +++ b/src/main/java/synk/meeteam/domain/common/skill/repository/SkillRepositoryCustomImpl.java @@ -18,7 +18,7 @@ public List findAllByKeywordAndTopLimit(String keyword, long limit) { return queryFactory .select(new QSkillDto(skill.id, skill.name)) .from(skill) - .where(skill.name.startsWith(keyword)) + .where(skill.name.contains(keyword)) .limit(limit) .orderBy(skill.name.asc().nullsLast()) .fetch(); diff --git a/src/main/java/synk/meeteam/domain/common/tag/repository/TagRepositoryCustomImpl.java b/src/main/java/synk/meeteam/domain/common/tag/repository/TagRepositoryCustomImpl.java index 1d70436b..bf1db443 100644 --- a/src/main/java/synk/meeteam/domain/common/tag/repository/TagRepositoryCustomImpl.java +++ b/src/main/java/synk/meeteam/domain/common/tag/repository/TagRepositoryCustomImpl.java @@ -34,7 +34,7 @@ public List findAllByKeywordAndTopLimitAndType(String keyword, lon return queryFactory .select(new QSearchTagDto(tag.id, tag.name)) .from(tag) - .where(tag.name.startsWith(keyword).and(tag.type.eq(type))) + .where(tag.name.contains(keyword).and(tag.type.eq(type))) .limit(limit) .orderBy(tag.name.asc().nullsLast()) .fetch(); diff --git a/src/main/java/synk/meeteam/domain/common/university/entity/University.java b/src/main/java/synk/meeteam/domain/common/university/entity/University.java index 16530f57..b6d0de64 100644 --- a/src/main/java/synk/meeteam/domain/common/university/entity/University.java +++ b/src/main/java/synk/meeteam/domain/common/university/entity/University.java @@ -23,11 +23,11 @@ public class University { private Long id; @NotNull - @Column(length = 20) + @Column(length = 70) private String name; @NotNull - @Column(length = 20) + @Column(length = 30) private String emailRegex; public University(String name, String emailRegex) { diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioApi.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioApi.java index dd38c3d2..97cb0063 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioApi.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioApi.java @@ -5,13 +5,17 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.dto.request.CreatePortfolioRequestDto; import synk.meeteam.domain.portfolio.portfolio.dto.request.UpdatePortfolioRequestDto; import synk.meeteam.domain.portfolio.portfolio.dto.response.GetPortfolioResponseDto; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.dto.PaginationPortfolioDto; import synk.meeteam.security.AuthUser; @Tag(name = "Portfolio", description = "포트폴리오 관련 API") @@ -44,4 +48,23 @@ public interface PortfolioApi { ResponseEntity modifyPortfolio(@AuthUser User user, @PathVariable Long portfolioId, @RequestBody @Valid UpdatePortfolioRequestDto requestDto); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "포트폴리오 삭제 성공"), + } + ) + @Operation(summary = "포트폴리오 삭제 API") + ResponseEntity deletePortfolio(@AuthUser User user, @PathVariable Long portfolioId); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "포트폴리오 목록 조회 성공"), + } + ) + @Operation(summary = "내 포트폴리오 목록 조회 API") + ResponseEntity> getMyPortfolios( + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @AuthUser User user); } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioController.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioController.java index 34d623d3..458d6315 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioController.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/api/PortfolioController.java @@ -1,20 +1,26 @@ package synk.meeteam.domain.portfolio.portfolio.api; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; 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.RestController; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.dto.request.CreatePortfolioRequestDto; import synk.meeteam.domain.portfolio.portfolio.dto.request.UpdatePortfolioRequestDto; import synk.meeteam.domain.portfolio.portfolio.dto.response.GetPortfolioResponseDto; import synk.meeteam.domain.portfolio.portfolio.service.PortfolioFacade; +import synk.meeteam.domain.portfolio.portfolio.service.PortfolioService; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.dto.PaginationPortfolioDto; import synk.meeteam.security.AuthUser; @RestController @@ -23,6 +29,7 @@ public class PortfolioController implements PortfolioApi { private final PortfolioFacade portfolioFacade; + private final PortfolioService portfolioService; @PostMapping @Override @@ -44,4 +51,20 @@ public ResponseEntity modifyPortfolio(@AuthUser User user, @PathVariable(" @RequestBody @Valid UpdatePortfolioRequestDto requestDto) { return ResponseEntity.ok(portfolioFacade.editPortfolio(portfolioId, user, requestDto)); } + + @DeleteMapping("/{id}") + @Override + public ResponseEntity deletePortfolio(@AuthUser User user, @PathVariable("id") Long portfolioId) { + portfolioService.deletePortfolio(portfolioId, user); + return ResponseEntity.ok().build(); + } + + @GetMapping + @Override + public ResponseEntity> getMyPortfolios( + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @AuthUser User user) { + return ResponseEntity.ok().body(portfolioService.getPageMyAllPortfolio(page, size, user)); + } } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/GetProfilePortfolioDto.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/SimplePortfolioDto.java similarity index 82% rename from src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/GetProfilePortfolioDto.java rename to src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/SimplePortfolioDto.java index c045ca7b..931ecb3e 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/GetProfilePortfolioDto.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/SimplePortfolioDto.java @@ -8,7 +8,7 @@ @Data @NoArgsConstructor -public class GetProfilePortfolioDto { +public class SimplePortfolioDto { @Schema(description = "포트폴리오 id", example = "1") private Long id; @Schema(description = "제목", example = "Meeteam") @@ -26,9 +26,9 @@ public class GetProfilePortfolioDto { @Builder @QueryProjection - public GetProfilePortfolioDto(Long id, String title, String mainImageUrl, String field, String role, - boolean isPinned, - int pinOrder) { + public SimplePortfolioDto(Long id, String title, String mainImageUrl, String field, String role, + boolean isPinned, + int pinOrder) { this.id = id; this.title = title; this.mainImageUrl = mainImageUrl; diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/CreatePortfolioRequestDto.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/CreatePortfolioRequestDto.java index 404456f3..930ed33e 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/CreatePortfolioRequestDto.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/CreatePortfolioRequestDto.java @@ -1,6 +1,7 @@ package synk.meeteam.domain.portfolio.portfolio.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; @@ -45,7 +46,7 @@ public record CreatePortfolioRequestDto( @NotNull String proceedType, @NotNull - @Pattern(regexp = "^(\\S+(\\.(?i)(jpg|png|gif|bmp))$)") + @Pattern(regexp = "^(\\S+(\\.(?i)(jpg|png|gif|bmp|jpeg))$)") String mainImageFileName, @NotNull @Pattern(regexp = "^(\\S+(\\.(?i)(zip))$)") @@ -55,8 +56,10 @@ public record CreatePortfolioRequestDto( List fileOrder, @Schema(description = "스킬", example = "[1,2,3]") @NotNull + @Size(max = 10) List skills, @NotNull - List links + @Size(max = 10) + List<@Valid PortfolioLinkDto> links ) { } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/UpdatePortfolioRequestDto.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/UpdatePortfolioRequestDto.java index 686d4a2d..649ade25 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/UpdatePortfolioRequestDto.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/request/UpdatePortfolioRequestDto.java @@ -1,6 +1,7 @@ package synk.meeteam.domain.portfolio.portfolio.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDate; @@ -29,6 +30,7 @@ public record UpdatePortfolioRequestDto( String content, @Schema(description = "zip 파일 순서", example = "[\"이미지1.png\",\"이미지2.jpg\"]") @NotNull + @Size(min = 1, max = 15) List fileOrder, @Schema(description = "분야", example = "1") @NotNull @@ -47,8 +49,11 @@ public record UpdatePortfolioRequestDto( String proceedType, @Schema(description = "스킬", example = "[1,2,3]") @NotNull + @Size(max = 10) List skills, + @Schema(description = "링크") @NotNull - List links + @Size(max = 10) + List<@Valid PortfolioLinkDto> links ) { } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetPortfolioResponseDto.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetPortfolioResponseDto.java index 9d03bddc..65977ab2 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetPortfolioResponseDto.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetPortfolioResponseDto.java @@ -1,11 +1,13 @@ package synk.meeteam.domain.portfolio.portfolio.dto.response; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.List; import synk.meeteam.domain.common.skill.dto.SkillDto; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio_link.dto.PortfolioLinkDto; +import synk.meeteam.global.util.UnescapedFieldSerializer; public record GetPortfolioResponseDto( @Schema(description = "제목", example = "Meeteam") @@ -22,6 +24,7 @@ public record GetPortfolioResponseDto( + "이를 위해 함께 멋진 서비스를 완성할 웹 디자이너를 찾고 있어요!\n" + "밋팀(Meeteam)은 나 자신을 의미하는 Me, 팀을 의미하는 Team, 만남을 의미하는 Meet이 합쳐진 단어입니다.\n" + "대학생들의 보다 원활한 팀프로젝트를 위해 기획하게 되었으며, 그 외에 포토폴리오로서의 기능까지 생각하고 있습니다!\n") + @JsonSerialize(using = UnescapedFieldSerializer.class) String content, @Schema(description = "zip 파일 url", example = "https://file.zip") String zipFileUrl, @@ -39,8 +42,11 @@ public record GetPortfolioResponseDto( String proceedType, List skills, List links, - List otherPortfolios, + List otherPortfolios, @Schema(description = "작성자여부", example = "true") - boolean isWriter + boolean isWriter, + @Schema(description = "작성자 닉네임", example = "goder") + String writerNickname + ) { } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetUserPortfolioResponseDto.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetUserPortfolioResponseDto.java index 8749abe7..4f76c428 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetUserPortfolioResponseDto.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/dto/response/GetUserPortfolioResponseDto.java @@ -1,11 +1,11 @@ package synk.meeteam.domain.portfolio.portfolio.dto.response; import java.util.List; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.global.dto.SliceInfo; public record GetUserPortfolioResponseDto( - List portfolios, + List portfolios, SliceInfo pageInfo ) { diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/Portfolio.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/Portfolio.java index 8fe6a650..9319e7b6 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/Portfolio.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/Portfolio.java @@ -1,6 +1,7 @@ package synk.meeteam.domain.portfolio.portfolio.entity; import static jakarta.persistence.FetchType.LAZY; +import static synk.meeteam.domain.portfolio.portfolio.exception.PortfolioExceptionType.NOT_YOUR_PORTFOLIO; import jakarta.persistence.Column; import jakarta.persistence.Convert; @@ -16,7 +17,6 @@ import jakarta.validation.constraints.Size; import java.time.LocalDate; import java.util.List; -import java.util.Objects; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -26,7 +26,9 @@ import org.hibernate.annotations.ColumnDefault; import synk.meeteam.domain.common.field.entity.Field; import synk.meeteam.domain.common.role.entity.Role; +import synk.meeteam.domain.portfolio.portfolio.exception.PortfolioException; import synk.meeteam.global.entity.BaseEntity; +import synk.meeteam.global.entity.DeleteStatus; import synk.meeteam.global.entity.ProceedType; import synk.meeteam.global.util.StringListConverter; @@ -35,7 +37,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.MODULE) -@Builder public class Portfolio extends BaseEntity { public static int MAX_PIN_SIZE = 8; @@ -100,10 +101,19 @@ public class Portfolio extends BaseEntity { @ColumnDefault("0") private int pinOrder; + //삭제 여부 + @NotNull + @Enumerated(EnumType.STRING) + @ColumnDefault("'ALIVE'") + private DeleteStatus deleteStatus = DeleteStatus.ALIVE; + @Builder - public Portfolio(String title, String description, String content, LocalDate proceedStart, LocalDate proceedEnd, + public Portfolio(Long id, String title, String description, String content, LocalDate proceedStart, + LocalDate proceedEnd, ProceedType proceedType, Field field, Role role, String mainImageFileName, String zipFileName, + Boolean isPin, int pinOrder, List fileOrder) { + this.id = id; this.title = title; this.description = description; this.content = content; @@ -115,15 +125,6 @@ public Portfolio(String title, String description, String content, LocalDate pro this.mainImageFileName = mainImageFileName; this.zipFileName = zipFileName; this.fileOrder = fileOrder; - this.isPin = false; - this.pinOrder = 0; - } - - @Builder - public Portfolio(Long id, String title, String description, Boolean isPin, int pinOrder) { - this.id = id; - this.title = title; - this.description = description; this.isPin = isPin; this.pinOrder = pinOrder; } @@ -147,7 +148,13 @@ public boolean isAllViewAble(Long userId) { } public boolean isWriter(Long userId) { - return Objects.equals(getCreatedBy(), userId); + return getCreatedBy().equals(userId); + } + + public void validWriter(Long userId) { + if (!isWriter(userId)) { + throw new PortfolioException(NOT_YOUR_PORTFOLIO); + } } public void putPin(int order) { @@ -159,4 +166,8 @@ public void unpin() { isPin = false; pinOrder = 0; } + + public void softDelete() { + this.deleteStatus = DeleteStatus.DELETED; + } } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/PortfolioMapper.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/PortfolioMapper.java index 348739ed..a5307a5c 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/PortfolioMapper.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/entity/PortfolioMapper.java @@ -6,7 +6,7 @@ import org.mapstruct.ReportingPolicy; import synk.meeteam.domain.common.field.entity.Field; import synk.meeteam.domain.common.role.entity.Role; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; @Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = "spring") public interface PortfolioMapper { @@ -24,5 +24,5 @@ static String toRole(Role role) { @Mapping(target = "field", qualifiedByName = "toField") @Mapping(target = "role", qualifiedByName = "toRole") @Mapping(source = "portfolio.isPin", target = "isPinned") - GetProfilePortfolioDto toGetProfilePortfolioDto(Portfolio portfolio, String mainImageUrl); + SimplePortfolioDto toGetProfilePortfolioDto(Portfolio portfolio, String mainImageUrl); } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/exception/PortfolioExceptionType.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/exception/PortfolioExceptionType.java index f89e90a7..d7ba20ac 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/exception/PortfolioExceptionType.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/exception/PortfolioExceptionType.java @@ -8,7 +8,7 @@ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public enum PortfolioExceptionType implements ExceptionType { SS_110(HttpStatus.BAD_REQUEST, "SS-110"), - NOT_FOUND_PORTFOLIO(HttpStatus.BAD_REQUEST, "포트폴리오를 찾을 수 없습니다."), + NOT_FOUND_PORTFOLIO(HttpStatus.NOT_FOUND, "포트폴리오를 찾을 수 없습니다."), OVER_MAX_PIN_SIZE(HttpStatus.BAD_REQUEST, "포트폴리오의 최대 핀 개수를 초과합니다."), NOT_YOUR_PORTFOLIO(HttpStatus.FORBIDDEN, "본인의 포트폴리오가 아닙니다."); diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepository.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepository.java index f2a3caaa..ba3e9411 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepository.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepository.java @@ -1,14 +1,17 @@ package synk.meeteam.domain.portfolio.portfolio.repository; import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.user.user.entity.User; public interface PortfolioCustomRepository { - Slice findUserPortfoliosByUserOrderByCreatedAtDesc(Pageable pageable, User user); + Slice findSlicePortfoliosByUserOrderByCreatedAtDesc(Pageable pageable, User user); + + Page findPaginationPortfoliosByUserOrderByCreatedAtDesc(Pageable pageable, User user); List findAllByCreatedByAndIsPinTrueOrderByIds(Long userId, List portfolioIds); } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepositoryImpl.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepositoryImpl.java index fb74d7af..3b4b1944 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepositoryImpl.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioCustomRepositoryImpl.java @@ -4,20 +4,25 @@ import static synk.meeteam.domain.common.role.entity.QRole.role; import static synk.meeteam.domain.portfolio.portfolio.entity.QPortfolio.portfolio; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; +import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; -import synk.meeteam.domain.portfolio.portfolio.dto.QGetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.QSimplePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.entity.DeleteStatus; @Repository @RequiredArgsConstructor @@ -33,15 +38,30 @@ public List findAllByCreatedByAndIsPinTrueOrderByIds(Long userId, Lis return queryFactory .selectFrom(portfolio) - .where(portfolio.id.in(portfolioIds)) - .orderBy(orderPortfolios(portfolioIds).asc()) + .where(portfolio.id.in(portfolioIds), + portfolio.createdBy.eq(userId), + isAlive() + ) + .orderBy(orderByPin(portfolioIds).asc()) .fetch(); } @Override - public Slice findUserPortfoliosByUserOrderByCreatedAtDesc(Pageable pageable, User user) { - List contents = queryFactory - .select(new QGetProfilePortfolioDto( + public Slice findSlicePortfoliosByUserOrderByCreatedAtDesc(Pageable pageable, User user) { + List contents = getSimplePortfolios(user, pageable, pageable.getPageSize() + 1); + return new SliceImpl<>(contents, pageable, hasNextPage(contents, pageable.getPageSize())); + } + + @Override + public Page findPaginationPortfoliosByUserOrderByCreatedAtDesc(Pageable pageable, User user) { + List contents = getSimplePortfolios(user, pageable, pageable.getPageSize()); + JPAQuery countQuery = getCount(user); + return PageableExecutionUtils.getPage(contents, pageable, countQuery::fetchOne); + } + + private List getSimplePortfolios(User user, Pageable pageable, int limit) { + return queryFactory + .select(new QSimplePortfolioDto( portfolio.id, portfolio.title, portfolio.mainImageFileName, @@ -53,15 +73,16 @@ public Slice findUserPortfoliosByUserOrderByCreatedAtDes .from(portfolio) .leftJoin(portfolio.role, role) .leftJoin(portfolio.field, field) - .where(portfolio.createdBy.eq(user.getId())) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize() + 1) + .where(portfolio.createdBy.eq(user.getId()), + isAlive()) .orderBy(portfolio.createdAt.desc(), portfolio.id.desc()) + .offset(pageable.getOffset()) + .limit(limit) .fetch(); - return new SliceImpl<>(contents, pageable, hasNextPage(contents, pageable.getPageSize())); } - private boolean hasNextPage(List contents, int pageSize) { + + private boolean hasNextPage(List contents, int pageSize) { if (contents.size() > pageSize) { contents.remove(pageSize); return true; @@ -69,8 +90,14 @@ private boolean hasNextPage(List contents, int pageSize) return false; } - NumberExpression orderPortfolios(List ids) { - // 포트폴리오 ID의 위치를 기준으로 순서를 정의합니다. + private JPAQuery getCount(User user) { + return queryFactory.select(portfolio.countDistinct()) + .from(portfolio) + .where(portfolio.createdBy.eq(user.getId()), isAlive()); + } + + NumberExpression orderByPin(List ids) { + // 포트폴리오 ID의 순서를 기준으로 순서를 정의합니다. CaseBuilder caseBuilder = new CaseBuilder(); return caseBuilder @@ -78,4 +105,8 @@ NumberExpression orderPortfolios(List ids) { .otherwise(ids.size()); } + BooleanExpression isAlive() { + return portfolio.deleteStatus.eq(DeleteStatus.ALIVE); + } + } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioRepository.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioRepository.java index 175982da..d955fd9f 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioRepository.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/repository/PortfolioRepository.java @@ -5,25 +5,40 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.portfolio.portfolio.exception.PortfolioException; +import synk.meeteam.global.entity.DeleteStatus; public interface PortfolioRepository extends JpaRepository, PortfolioCustomRepository { List findAllByCreatedByAndIsPinTrue(Long userId); - List findAllByIsPinTrueAndCreatedByOrderByPinOrderAsc(Long id); + List findAllByIsPinTrueAndCreatedByOrderByPinOrderAsc(Long userId); - @Query("SELECT p FROM Portfolio p LEFT JOIN FETCH p.role LEFT JOIN FETCH p.field where p.id=:id") - Optional findByIdWithFieldAndRole(@Param("id") Long portfolioId); + @Query("SELECT p FROM Portfolio p LEFT JOIN FETCH p.role LEFT JOIN FETCH p.field where p.id=:id and p.deleteStatus=:deleteStatus") + Optional findByIdWithFieldAndRoleAndDeleteStatus(@Param("id") Long portfolioId, + @Param("deleteStatus") DeleteStatus deleteStatus); - default Portfolio findByIdOrElseThrow(Long portfolioId) { - return findById(portfolioId).orElseThrow(() -> new PortfolioException(NOT_FOUND_PORTFOLIO)); + Optional findByIdAndDeleteStatus(Long portfolioId, DeleteStatus deleteStatus); + + default Portfolio findByIdAndAliveOrElseThrow(Long portfolioId) { + return findByIdAndDeleteStatus(portfolioId, DeleteStatus.ALIVE).orElseThrow( + () -> new PortfolioException(NOT_FOUND_PORTFOLIO)); } - default Portfolio findByIdWithFieldAndRoleOrElseThrow(Long portfolioId) { - return findByIdWithFieldAndRole(portfolioId).orElseThrow(() -> new PortfolioException(NOT_FOUND_PORTFOLIO)); + default Portfolio findByIdAndAliveWithFieldAndRoleOrElseThrow(Long portfolioId) { + return findByIdWithFieldAndRoleAndDeleteStatus(portfolioId, DeleteStatus.ALIVE).orElseThrow( + () -> new PortfolioException(NOT_FOUND_PORTFOLIO)); } + List findAllByCreatedBy(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM Portfolio p WHERE p.id IN :portfolioIds") + void deleteAllByIdsInQuery(List portfolioIds); + } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioFacade.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioFacade.java index f017a69f..10c808f3 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioFacade.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioFacade.java @@ -20,8 +20,9 @@ import synk.meeteam.domain.portfolio.portfolio_link.service.PortfolioLinkService; import synk.meeteam.domain.portfolio.portfolio_skill.service.PortfolioSkillService; import synk.meeteam.domain.user.user.entity.User; -import synk.meeteam.infra.s3.S3FileName; -import synk.meeteam.infra.s3.service.S3Service; +import synk.meeteam.domain.user.user.service.UserService; +import synk.meeteam.infra.aws.S3FilePath; +import synk.meeteam.infra.aws.service.CloudFrontService; @Service @RequiredArgsConstructor @@ -29,7 +30,8 @@ public class PortfolioFacade { private final PortfolioService portfolioService; private final PortfolioSkillService portfolioSkillService; private final PortfolioLinkService portfolioLinkService; - private final S3Service s3Service; + private final CloudFrontService cloudFrontService; + private final UserService userService; private final PortfolioCommandMapper commandMapper; private final PortfolioMapper portfolioMapper; @@ -46,13 +48,15 @@ public Long postPortfolio(CreatePortfolioRequestDto requestDto) { @Transactional(readOnly = true) public GetPortfolioResponseDto getPortfolio(Long portfolioId, User user) { Portfolio portfolio = portfolioService.getPortfolio(portfolioId); - if (!portfolio.isAllViewAble(user.getId())) { + Long userId = user != null ? user.getId() : null; + if (!portfolio.isAllViewAble(userId)) { throw new PortfolioException(NOT_YOUR_PORTFOLIO); } + User writer = userService.findById(portfolio.getCreatedBy()); List skills = portfolioSkillService.getPortfolioSkill(portfolio); List links = portfolioLinkService.getPortfolioLink(portfolio); - String zipFileUrl = s3Service.createPreSignedGetUrl(S3FileName.PORTFOLIO, + String zipFileUrl = cloudFrontService.getSignedUrl(S3FilePath.getPortfolioPath(user.getEncryptUserId()), portfolio.getZipFileName()); List otherPinPortfolios = getUserPortfolio(portfolio); return new GetPortfolioResponseDto( @@ -70,18 +74,17 @@ public GetPortfolioResponseDto getPortfolio(Long portfolioId, User user) { links.stream().map(link -> new PortfolioLinkDto(link.getUrl(), link.getDescription())).toList(), otherPinPortfolios.stream().map(otherPortfolio -> portfolioMapper.toGetProfilePortfolioDto(otherPortfolio, - s3Service.createPreSignedGetUrl(S3FileName.PORTFOLIO, + cloudFrontService.getSignedUrl(S3FilePath.getPortfolioPath(user.getEncryptUserId()), otherPortfolio.getMainImageFileName()))).toList(), - portfolio.isWriter(user.getId()) + portfolio.isWriter(userId), + writer.getNickname() ); } @Transactional public Long editPortfolio(Long portfolioId, User user, UpdatePortfolioRequestDto requestDto) { Portfolio portfolio = portfolioService.getPortfolio(portfolioId); - if (!portfolio.isWriter(user.getId())) { - throw new PortfolioException(NOT_YOUR_PORTFOLIO); - } + portfolio.validWriter(user.getId()); portfolioService.editPortfolio(portfolio, user, commandMapper.toUpdatePortfolioCommand(requestDto)); portfolioSkillService.editPortfolioSkill(portfolio, requestDto.skills()); portfolioLinkService.editPortfolioLink(portfolio, requestDto.links()); diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioService.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioService.java index 50090980..caf31612 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioService.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioService.java @@ -1,25 +1,31 @@ package synk.meeteam.domain.portfolio.portfolio.service; import java.util.List; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.dto.command.CreatePortfolioCommand; import synk.meeteam.domain.portfolio.portfolio.dto.command.UpdatePortfolioCommand; import synk.meeteam.domain.portfolio.portfolio.dto.response.GetUserPortfolioResponseDto; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.dto.PaginationPortfolioDto; public interface PortfolioService { List changePinPortfoliosByIds(Long userId, List portfolioIds); List getMyPinPortfolio(Long userId); - GetUserPortfolioResponseDto getMyAllPortfolio(int page, int size, User user); + GetUserPortfolioResponseDto getSliceMyAllPortfolio(int page, int size, User user); + + PaginationPortfolioDto getPageMyAllPortfolio(int page, int size, User user); Portfolio postPortfolio(CreatePortfolioCommand command); Portfolio getPortfolio(Long portfolioId); - + Portfolio getPortfolio(Long portfolioId, User user); Portfolio editPortfolio(Portfolio portfolio, User user, UpdatePortfolioCommand command); + void deletePortfolio(Long portfolioId, User user); + } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioServiceImpl.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioServiceImpl.java index a0fc17bb..d9fa6315 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioServiceImpl.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio/service/PortfolioServiceImpl.java @@ -6,6 +6,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -15,7 +16,7 @@ import synk.meeteam.domain.common.field.repository.FieldRepository; import synk.meeteam.domain.common.role.entity.Role; import synk.meeteam.domain.common.role.repository.RoleRepository; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.dto.command.CreatePortfolioCommand; import synk.meeteam.domain.portfolio.portfolio.dto.command.UpdatePortfolioCommand; import synk.meeteam.domain.portfolio.portfolio.dto.response.GetUserPortfolioResponseDto; @@ -23,7 +24,11 @@ import synk.meeteam.domain.portfolio.portfolio.exception.PortfolioException; import synk.meeteam.domain.portfolio.portfolio.repository.PortfolioRepository; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.dto.PageInfo; +import synk.meeteam.global.dto.PaginationPortfolioDto; import synk.meeteam.global.dto.SliceInfo; +import synk.meeteam.infra.aws.S3FilePath; +import synk.meeteam.infra.aws.service.CloudFrontService; @Service @RequiredArgsConstructor @@ -32,6 +37,8 @@ public class PortfolioServiceImpl implements PortfolioService { private final FieldRepository fieldRepository; private final RoleRepository roleRepository; + private final CloudFrontService cloudFrontService; + @Transactional public List changePinPortfoliosByIds(Long userId, List portfolioIds) { @@ -66,15 +73,35 @@ public List getMyPinPortfolio(Long userId) { } @Override - public GetUserPortfolioResponseDto getMyAllPortfolio(int page, int size, User user) { + @Transactional(readOnly = true) + public GetUserPortfolioResponseDto getSliceMyAllPortfolio(int page, int size, User user) { int pageNumber = page - 1; Pageable pageable = PageRequest.of(pageNumber, size); - Slice userPortfolioDtos = portfolioRepository.findUserPortfoliosByUserOrderByCreatedAtDesc( + Slice userPortfolioDtos = portfolioRepository.findSlicePortfoliosByUserOrderByCreatedAtDesc( pageable, user); + userPortfolioDtos.getContent().forEach(userPortfolio -> { + String imageUrl = cloudFrontService.getSignedUrl(S3FilePath.getPortfolioPath(user.getEncryptUserId()), + userPortfolio.getMainImageUrl()); + userPortfolio.setMainImageUrl(imageUrl); + }); SliceInfo pageInfo = new SliceInfo(page, size, userPortfolioDtos.hasNext()); return new GetUserPortfolioResponseDto(userPortfolioDtos.getContent(), pageInfo); } + @Override + @Transactional(readOnly = true) + public PaginationPortfolioDto getPageMyAllPortfolio(int page, int size, User user) { + Page myPortfolios = portfolioRepository.findPaginationPortfoliosByUserOrderByCreatedAtDesc( + PageRequest.of(page - 1, size), user); + myPortfolios.getContent().forEach(myPortfolio -> { + String imageUrl = cloudFrontService.getSignedUrl(S3FilePath.getPortfolioPath(user.getEncryptUserId()), + myPortfolio.getMainImageUrl()); + myPortfolio.setMainImageUrl(imageUrl); + }); + PageInfo pageInfo = new PageInfo(page, size, myPortfolios.getTotalElements(), myPortfolios.getTotalPages()); + return new PaginationPortfolioDto<>(myPortfolios.getContent(), pageInfo); + } + @Override @Transactional public Portfolio postPortfolio(CreatePortfolioCommand command) { @@ -100,6 +127,7 @@ public Portfolio postPortfolio(CreatePortfolioCommand command) { } @Override + @Transactional public Portfolio editPortfolio(Portfolio portfolio, User user, UpdatePortfolioCommand command) { Field field = fieldRepository.findByIdOrElseThrowException(command.fieldId()); Role role = roleRepository.findByIdOrElseThrowException(command.roleId()); @@ -117,13 +145,35 @@ public Portfolio editPortfolio(Portfolio portfolio, User user, UpdatePortfolioCo return portfolio; } + @Transactional + @Override + public void deletePortfolio(Long portfolioId, User user) { + Portfolio portfolio = portfolioRepository.findByIdAndAliveOrElseThrow(portfolioId); + portfolio.validWriter(user.getId()); + if (portfolio.getIsPin()) { + reorderPinPortfolio(user, portfolio); + } + portfolio.softDelete(); + } + + private void reorderPinPortfolio(User user, Portfolio portfolio) { + List pinPortfolios = portfolioRepository.findAllByIsPinTrueAndCreatedByOrderByPinOrderAsc( + user.getId()); + for (int index = 0; index < pinPortfolios.size(); index++) { + pinPortfolios.get(index).putPin(index + 1); + } + portfolio.unpin(); + } + + @Transactional(readOnly = true) @Override public Portfolio getPortfolio(Long portfolioId) { - return portfolioRepository.findByIdWithFieldAndRoleOrElseThrow(portfolioId); + return portfolioRepository.findByIdAndAliveWithFieldAndRoleOrElseThrow(portfolioId); } + @Transactional(readOnly = true) public Portfolio getPortfolio(Long portfolioId, User user) { - Portfolio portfolio = portfolioRepository.findByIdOrElseThrow(portfolioId); + Portfolio portfolio = portfolioRepository.findByIdAndAliveOrElseThrow(portfolioId); if (!portfolio.getCreatedBy().equals(user.getId())) { throw new PortfolioException(SS_110); diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/dto/PortfolioLinkDto.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/dto/PortfolioLinkDto.java index 33a67f61..87d2c7c6 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/dto/PortfolioLinkDto.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/dto/PortfolioLinkDto.java @@ -1,11 +1,15 @@ package synk.meeteam.domain.portfolio.portfolio_link.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; public record PortfolioLinkDto( - @Schema(description = "url", example = "http://~~") + @Schema(description = "url", example = "https://~~") + @Pattern(regexp = "https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#()?&/=]*)") String url, @Schema(description = "부연설명", example = "Github") + @Size(max = 20) String description ) { } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/repository/PortfolioLinkRepository.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/repository/PortfolioLinkRepository.java index 6606a7e3..05d383f6 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/repository/PortfolioLinkRepository.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio_link/repository/PortfolioLinkRepository.java @@ -2,6 +2,9 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.portfolio.portfolio_link.entity.PortfolioLink; @@ -9,4 +12,9 @@ public interface PortfolioLinkRepository extends JpaRepository findAllByPortfolio(Portfolio portfolio); void deleteAllByPortfolio(Portfolio portfolio); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM PortfolioLink p WHERE p.portfolio.id IN :portfolioIds") + void deleteAllByPortfolioIdsInQuery(List portfolioIds); } diff --git a/src/main/java/synk/meeteam/domain/portfolio/portfolio_skill/repository/PortfolioSkillRepository.java b/src/main/java/synk/meeteam/domain/portfolio/portfolio_skill/repository/PortfolioSkillRepository.java index 2c15be7c..2387d9d3 100644 --- a/src/main/java/synk/meeteam/domain/portfolio/portfolio_skill/repository/PortfolioSkillRepository.java +++ b/src/main/java/synk/meeteam/domain/portfolio/portfolio_skill/repository/PortfolioSkillRepository.java @@ -2,8 +2,10 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.portfolio.portfolio_skill.entity.PortfolioSkill; @@ -12,4 +14,9 @@ public interface PortfolioSkillRepository extends JpaRepository findAllByPortfolioWithSkill(@Param("portfolio") Portfolio portfolio); void deleteAllByPortfolio(Portfolio portfolio); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM PortfolioSkill p WHERE p.portfolio.id IN :portfolioIds") + void deleteAllByPortfolioIdsInQuery(List portfolioIds); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/bookmark/repository/BookmarkRepository.java b/src/main/java/synk/meeteam/domain/recruitment/bookmark/repository/BookmarkRepository.java index 71b9b188..bd7e3e83 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/synk/meeteam/domain/recruitment/bookmark/repository/BookmarkRepository.java @@ -1,7 +1,11 @@ package synk.meeteam.domain.recruitment.bookmark.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.recruitment.bookmark.entity.Bookmark; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.user.user.entity.User; @@ -10,4 +14,14 @@ public interface BookmarkRepository extends JpaRepository { Optional findByRecruitmentPostAndUser(RecruitmentPost recruitmentPost, User user); void deleteByRecruitmentPostAndUser(RecruitmentPost recruitmentPost, User user); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM Bookmark b WHERE b.user.id = :userId") + void deleteAllByUserId(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM Bookmark b WHERE b.recruitmentPost.id IN :postIds") + void deleteAllByPostIdInQuery(List postIds); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantApi.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantApi.java index b7e5251a..cd18a004 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantApi.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantApi.java @@ -36,6 +36,14 @@ ResponseEntity setLink(@PathVariable("id") Long postId, @Valid @RequestBod @Operation(summary = "신청 관리 정보 조회 API") ResponseEntity getApplyInfo(@PathVariable("id") Long postId, @AuthUser User user); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "방문 처리 성"), + } + ) + @Operation(summary = "최초 접근 후 호출 API") + ResponseEntity processFirstAccess(@PathVariable("id") Long postId, @AuthUser User user); + @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "신청자 승인 성공"), diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantController.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantController.java index 0581a5e3..e16c8e26 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantController.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/api/RecruitmentApplicantController.java @@ -26,6 +26,7 @@ import synk.meeteam.domain.recruitment.recruitment_role.entity.RecruitmentRole; import synk.meeteam.domain.recruitment.recruitment_role.service.RecruitmentRoleService; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.domain.user.user.service.UserService; import synk.meeteam.security.AuthUser; @RestController @@ -39,6 +40,8 @@ public class RecruitmentApplicantController implements RecruitmentApplicantApi { private final RecruitmentPostService recruitmentPostService; private final RecruitmentRoleService recruitmentRoleService; + private final UserService userService; + @PutMapping("/{id}/link") @Override public ResponseEntity setLink(@PathVariable("id") Long postId, @@ -55,7 +58,7 @@ public ResponseEntity getApplyInfo(@PathVariable("i @AuthUser User user) { RecruitmentPost recruitmentPost = recruitmentPostService.getRecruitmentPost(postId); List applyStatusRecruitmentRoles = recruitmentRoleService.findApplyStatusRecruitmentRole( - postId); + postId, user.getId(), recruitmentPost.getCreatedBy()); List roleStatusResponseDtos = applyStatusRecruitmentRoles.stream() .map(role -> GetRecruitmentRoleStatusResponseDto.of(role.getRole().getName(), role.getCount(), @@ -68,7 +71,16 @@ public ResponseEntity getApplyInfo(@PathVariable("i return ResponseEntity.ok() .body(new GetApplicantInfoResponseDto(recruitmentPost.getTitle(), recruitmentPost.getKakaoLink(), - roleStatusResponseDtos, roleDtos)); + user.isFirstApplicantAccess(), roleStatusResponseDtos, + roleDtos)); + } + + @PatchMapping("/{id}/access") + @Override + public ResponseEntity processFirstAccess(@PathVariable("id") Long postId, @AuthUser User user) { + userService.processFirstAccess(user); + + return ResponseEntity.ok().build(); } @PatchMapping("/{id}/approve") @@ -102,8 +114,9 @@ public ResponseEntity getApplicants(@PathVariable("id") @RequestParam(name = "page", defaultValue = "1") int page, @RequestParam(name = "size", defaultValue = "8") int size, @AuthUser User user) { - - GetApplicantResponseDto responseDtos = recruitmentApplicantService.getAllByRole(postId, roleId, page, size); + RecruitmentPost recruitmentPost = recruitmentPostService.getRecruitmentPost(postId); + GetApplicantResponseDto responseDtos = recruitmentApplicantService.getAllByRole(postId, roleId, user.getId(), + recruitmentPost.getCreatedBy(), page, size); return ResponseEntity.ok().body(responseDtos); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/dto/response/GetApplicantInfoResponseDto.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/dto/response/GetApplicantInfoResponseDto.java index 1046b8e3..8b69ae45 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/dto/response/GetApplicantInfoResponseDto.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/dto/response/GetApplicantInfoResponseDto.java @@ -9,6 +9,8 @@ public record GetApplicantInfoResponseDto( String title, @Schema(description = "오픈카톡방 링크", example = "https://open.kakao.com/o/gLmqdijg") String link, + @Schema(description = "처음 접속 여부", example = "true") + boolean isFirstAccess, List recruitmentStatus, List roles ) { diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/entity/DeleteStatus.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/entity/DeleteStatus.java deleted file mode 100644 index cc0d9624..00000000 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/entity/DeleteStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package synk.meeteam.domain.recruitment.recruitment_applicant.entity; - -public enum DeleteStatus { - ALIVE, DELETED -} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/entity/RecruitmentApplicant.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/entity/RecruitmentApplicant.java index 451693c3..aa511b1c 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/entity/RecruitmentApplicant.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/entity/RecruitmentApplicant.java @@ -25,6 +25,7 @@ import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.entity.BaseTimeEntity; +import synk.meeteam.global.entity.DeleteStatus; @Getter @Setter diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/facade/RecruitmentApplicantFacade.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/facade/RecruitmentApplicantFacade.java index 7b891650..4ba4b903 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/facade/RecruitmentApplicantFacade.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/facade/RecruitmentApplicantFacade.java @@ -39,14 +39,16 @@ public void approveApplicant(Long postId, Long userId, List applicantIds) RecruitmentPost recruitmentPost = recruitmentPostService.getRecruitmentPost(postId); User user = userService.findById(recruitmentPost.getCreatedBy()); - mailService.sendApproveMails(postId, applicants, user.getName()); + + applicants.stream().forEach( + applicant -> mailService.sendApproveMail(postId, applicant, user.getName()) + ); } @Transactional public void rejectApplicants(Long postId, Long userId, List applicantIds) { - List applicants = recruitmentApplicantService.getAllApplicants(applicantIds); - recruitmentApplicantService.rejectApplicants(applicants, applicantIds, userId); + recruitmentApplicantService.rejectApplicants(applicantIds, userId); recruitmentPostService.incrementResponseCount(postId, userId, applicantIds.size()); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantCustomRepositoryImpl.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantCustomRepositoryImpl.java index 063055cf..ff74e3dc 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantCustomRepositoryImpl.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantCustomRepositoryImpl.java @@ -20,8 +20,8 @@ import synk.meeteam.domain.common.department.entity.QDepartment; import synk.meeteam.domain.recruitment.recruitment_applicant.dto.response.GetApplicantDto; import synk.meeteam.domain.recruitment.recruitment_applicant.dto.response.QGetApplicantDto; -import synk.meeteam.domain.recruitment.recruitment_applicant.entity.DeleteStatus; import synk.meeteam.domain.recruitment.recruitment_applicant.entity.RecruitStatus; +import synk.meeteam.global.entity.DeleteStatus; @Repository @RequiredArgsConstructor @@ -51,7 +51,7 @@ public Slice findByPostIdAndRoleId(Long postId, Long roleId, Pa .select(new QGetApplicantDto(recruitmentApplicant.id, recruitmentApplicant.applicant.id.stringValue(), recruitmentApplicant.applicant.nickname, recruitmentApplicant.applicant.profileImgFileName, - recruitmentApplicant.applicant.name, recruitmentApplicant.applicant.evaluationScore, + recruitmentApplicant.applicant.name, recruitmentApplicant.applicant.gpa, recruitmentApplicant.applicant.university.name, recruitmentApplicant.applicant.department.name, getMainMail, recruitmentApplicant.applicant.admissionYear, recruitmentApplicant.role.name, recruitmentApplicant.comment)) diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantRepository.java index b2e87135..7b8d70f3 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantRepository.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/repository/RecruitmentApplicantRepository.java @@ -5,18 +5,20 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import synk.meeteam.domain.recruitment.recruitment_applicant.entity.DeleteStatus; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.recruitment.recruitment_applicant.entity.RecruitmentApplicant; import synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantException; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.entity.DeleteStatus; public interface RecruitmentApplicantRepository extends JpaRepository, RecruitmentApplicantCustomRepository { - @Query("SELECT a FROM RecruitmentApplicant a JOIN FETCH a.recruitmentPost WHERE a.id IN :ids AND a.deleteStatus = synk.meeteam.domain.recruitment.recruitment_applicant.entity.DeleteStatus.ALIVE") + @Query("SELECT a FROM RecruitmentApplicant a JOIN FETCH a.recruitmentPost JOIN FETCH a.applicant WHERE a.id IN :ids AND a.deleteStatus = synk.meeteam.global.entity.DeleteStatus.ALIVE") List findAllInApplicantId(@Param("ids") List applicantIds); Optional findByRecruitmentPostAndApplicantAndDeleteStatus(RecruitmentPost recruitmentPost, @@ -28,4 +30,14 @@ default RecruitmentApplicant findByRecruitmentPostAndApplicantOrElseThrow(Recrui return findByRecruitmentPostAndApplicantAndDeleteStatus(recruitmentPost, user, DeleteStatus.ALIVE) .orElseThrow(() -> new RecruitmentApplicantException(SS_600)); } + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM RecruitmentApplicant r WHERE r.recruitmentPost.id IN :postIds") + void deleteAllByPostIdInQuery(List postIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM RecruitmentApplicant r WHERE r.applicant.id = :userId") + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/service/RecruitmentApplicantService.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/service/RecruitmentApplicantService.java index 7ca084c4..629e68ed 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/service/RecruitmentApplicantService.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/service/RecruitmentApplicantService.java @@ -1,8 +1,9 @@ package synk.meeteam.domain.recruitment.recruitment_applicant.service; import static synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantExceptionType.INVALID_REQUEST; +import static synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantExceptionType.INVALID_USER; import static synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantExceptionType.SS_602; -import static synk.meeteam.infra.s3.S3FileName.USER; +import static synk.meeteam.infra.aws.S3FilePath.USER; import java.util.HashMap; import java.util.List; @@ -15,22 +16,23 @@ import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.recruitment.recruitment_applicant.dto.response.GetApplicantDto; import synk.meeteam.domain.recruitment.recruitment_applicant.dto.response.GetApplicantResponseDto; -import synk.meeteam.domain.recruitment.recruitment_applicant.entity.DeleteStatus; import synk.meeteam.domain.recruitment.recruitment_applicant.entity.RecruitStatus; import synk.meeteam.domain.recruitment.recruitment_applicant.entity.RecruitmentApplicant; import synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantException; import synk.meeteam.domain.recruitment.recruitment_applicant.repository.RecruitmentApplicantRepository; +import synk.meeteam.domain.recruitment.recruitment_applicant.service.vo.RecruitmentApplicants; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.dto.SliceInfo; +import synk.meeteam.global.entity.DeleteStatus; import synk.meeteam.global.util.Encryption; -import synk.meeteam.infra.s3.service.S3Service; +import synk.meeteam.infra.aws.service.CloudFrontService; @Service @RequiredArgsConstructor public class RecruitmentApplicantService { private final RecruitmentApplicantRepository recruitmentApplicantRepository; - private final S3Service s3Service; + private final CloudFrontService cloudFrontService; @Transactional public void registerRecruitmentApplicant(RecruitmentApplicant recruitmentApplicant) { @@ -77,22 +79,28 @@ public Map getRecruitedCounts(List approvedApp @Transactional public void approveApplicants(List applicants, List applicantIds, Long userId) { + // TODO : List 일급컬렉션으로 리팩토링 필요. validateCanProcess(applicants, userId); - validateApplicantCount(applicantIds.size(), applicants.size()); + validateApplicants(applicantIds.size(), applicants.size()); recruitmentApplicantRepository.updateRecruitStatus(applicantIds, RecruitStatus.APPROVED); } @Transactional - public void rejectApplicants(List applicants, List applicantIds, Long userId) { - validateCanProcess(applicants, userId); - validateApplicantCount(applicantIds.size(), applicants.size()); + public void rejectApplicants(List requestApplicantIds, Long writerId) { + List applicants = getAllApplicants(requestApplicantIds); + RecruitmentApplicants recruitmentApplicants = new RecruitmentApplicants(applicants, requestApplicantIds, + writerId); - recruitmentApplicantRepository.updateRecruitStatus(applicantIds, RecruitStatus.REJECTED); + recruitmentApplicantRepository.updateRecruitStatus(recruitmentApplicants.getRecruitmentApplicantIds(), + RecruitStatus.REJECTED); } @Transactional - public GetApplicantResponseDto getAllByRole(Long postId, Long roleId, int page, int size) { + public GetApplicantResponseDto getAllByRole(Long postId, Long roleId, Long userId, Long writerId, int page, + int size) { + validateIsWriter(userId, writerId); + int pageNumber = page - 1; Pageable pageable = PageRequest.of(pageNumber, size); @@ -100,7 +108,7 @@ public GetApplicantResponseDto getAllByRole(Long postId, Long roleId, int page, roleId, pageable); applicantDtos.stream().forEach(applicant -> applicant.setEncryptedUserIdAndProfileImg( Encryption.encryptLong(Long.parseLong(applicant.getUserId())), - s3Service.createPreSignedGetUrl(USER, applicant.getProfileImg()))); + cloudFrontService.getSignedUrl(USER, applicant.getProfileImg()))); SliceInfo pageInfo = new SliceInfo(page, size, applicantDtos.hasNext()); return new GetApplicantResponseDto(applicantDtos.getContent(), pageInfo); @@ -117,6 +125,12 @@ public boolean isAppliedUser(RecruitmentPost recruitmentPost, User user) { return true; } + private void validateIsWriter(Long userId, Long writerId) { + if (!userId.equals(writerId)) { + throw new RecruitmentApplicantException(INVALID_USER); + } + } + private void validateCanProcess(List applicants, Long userId) { // applicants 검증로직 // 호출한 사용자가 구인글 작성자인지 확인 @@ -126,7 +140,7 @@ private void validateCanProcess(List applicants, Long user .forEach(applicant -> applicant.validateCanApprove(userId)); } - private void validateApplicantCount(int requestCount, int actualCount) { + private void validateApplicants(int requestCount, int actualCount) { if (requestCount != actualCount) { throw new RecruitmentApplicantException(INVALID_REQUEST); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/service/vo/RecruitmentApplicants.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/service/vo/RecruitmentApplicants.java new file mode 100644 index 00000000..b1b9a438 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_applicant/service/vo/RecruitmentApplicants.java @@ -0,0 +1,51 @@ +package synk.meeteam.domain.recruitment.recruitment_applicant.service.vo; + +import static synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantExceptionType.INVALID_REQUEST; + +import java.util.List; +import synk.meeteam.domain.recruitment.recruitment_applicant.entity.RecruitmentApplicant; +import synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantException; + +public class RecruitmentApplicants { + private final List recruitmentApplicants; + + public RecruitmentApplicants(List recruitmentApplicants, List requestApplicantIds, + Long writerId) { + validateApplicants(recruitmentApplicants, requestApplicantIds); + validateCanProcess(recruitmentApplicants, writerId); + this.recruitmentApplicants = recruitmentApplicants; + } + + public List getRecruitmentApplicantIds() { + return recruitmentApplicants.stream() + .map(RecruitmentApplicant::getId) + .toList(); + } + + private void validateApplicants(List recruitmentApplicants, List requestApplicantIds) { + if (recruitmentApplicants.size() != requestApplicantIds.size()) { + throw new RecruitmentApplicantException(INVALID_REQUEST); + } + + List actualApplicantIds = recruitmentApplicants.stream() + .map(RecruitmentApplicant::getId) + .toList(); + + for (Long id : requestApplicantIds) { + if (!actualApplicantIds.contains(id)) { + throw new RecruitmentApplicantException(INVALID_REQUEST); + } + } + } + + private void validateCanProcess(List applicants, Long userId) { + // applicants 검증로직 + // 호출한 사용자가 구인글 작성자인지 확인 + // status가 다 NONE인지 확인 + + applicants.stream() + .forEach(applicant -> applicant.validateCanApprove(userId)); + } + + +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/repository/RecruitmentCommentRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/repository/RecruitmentCommentRepository.java index bdb1c753..882f569c 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/repository/RecruitmentCommentRepository.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/repository/RecruitmentCommentRepository.java @@ -2,8 +2,12 @@ import static synk.meeteam.domain.recruitment.recruitment_comment.exception.RecruitmentCommentExceptionType.INVALID_COMMENT; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.recruitment.recruitment_comment.entity.RecruitmentComment; import synk.meeteam.domain.recruitment.recruitment_comment.exception.RecruitmentCommentException; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; @@ -25,4 +29,9 @@ default RecruitmentComment findLatestGroupOrderOrElseThrow(RecruitmentPost recru default RecruitmentComment findByIdOrElseThrow(Long commentId) { return findById(commentId).orElseThrow(() -> new RecruitmentCommentException(INVALID_COMMENT)); } + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM RecruitmentComment r WHERE r.recruitmentPost.id IN :postIds") + void deleteAllByPostIdInQuery(List postIds); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/service/RecruitmentCommentService.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/service/RecruitmentCommentService.java index e4c7e015..cd423d41 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/service/RecruitmentCommentService.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_comment/service/RecruitmentCommentService.java @@ -15,15 +15,15 @@ import synk.meeteam.domain.recruitment.recruitment_post.dto.response.GetReplyResponseDto; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.global.util.Encryption; -import synk.meeteam.infra.s3.S3FileName; -import synk.meeteam.infra.s3.service.S3Service; +import synk.meeteam.infra.aws.S3FilePath; +import synk.meeteam.infra.aws.service.CloudFrontService; @Service @RequiredArgsConstructor public class RecruitmentCommentService { private final RecruitmentCommentRepository recruitmentCommentRepository; - private final S3Service s3Service; + private final CloudFrontService cloudFrontService; // 가공된 형태를 많이 사용할 것 같다. // 그래서 Dto를 바로 반환하는 식으로 만들었다. @@ -36,9 +36,7 @@ public List getRecruitmentComments(RecruitmentPost recrui for (RecruitmentCommentVO comment : commentVOs) { boolean isWriter = writerId.equals(comment.getUserId()); - String profileImg = s3Service.createPreSignedGetUrl( - S3FileName.USER, - comment.getProfileImg()); + String profileImg = cloudFrontService.getSignedUrl(S3FilePath.USER, comment.getProfileImg()); String commentWriterId = Encryption.encryptLong(comment.getUserId()); if (comment.isParent()) { diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/PostManagementApi.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/PostManagementApi.java new file mode 100644 index 00000000..5d3e1169 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/PostManagementApi.java @@ -0,0 +1,53 @@ +package synk.meeteam.domain.recruitment.recruitment_post.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.web.bind.annotation.RequestParam; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SimpleRecruitmentPostDto; +import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.dto.PaginationDto; +import synk.meeteam.security.AuthUser; + +@Tag(name = "management", description = "내 구인글 관리 관련 API") +public interface PostManagementApi { + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "구인글 조회 성공"), + } + ) + @Operation(summary = "북마크한 구인글 조회") + @SecurityRequirement(name = "Authorization") + PaginationDto getBookmarkPost(@AuthUser User user, + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @RequestParam(value = "is-closed", required = false) Boolean isClosed); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "구인글 조회 성공"), + } + ) + @Operation(summary = "내가 신청한 구인글 조회") + @SecurityRequirement(name = "Authorization") + PaginationDto getAppliedPost(@AuthUser User user, + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @RequestParam(value = "is-closed", required = false) Boolean isClosed); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "구인글 조회 성공"), + } + ) + @Operation(summary = "내가 작성한 구인글 조회") + @SecurityRequirement(name = "Authorization") + PaginationDto getMyPost(@AuthUser User user, + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @RequestParam(value = "is-closed", required = false) Boolean isClosed); +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/PostManagementController.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/PostManagementController.java new file mode 100644 index 00000000..afa893ba --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/PostManagementController.java @@ -0,0 +1,50 @@ +package synk.meeteam.domain.recruitment.recruitment_post.api; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import synk.meeteam.domain.recruitment.recruitment_post.dto.ManageType; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SimpleRecruitmentPostDto; +import synk.meeteam.domain.recruitment.recruitment_post.service.RecruitmentPostService; +import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.dto.PaginationDto; +import synk.meeteam.security.AuthUser; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/management") +public class PostManagementController implements PostManagementApi { + + private final RecruitmentPostService recruitmentPostService; + + @GetMapping("/bookmark") + @Override + public PaginationDto getBookmarkPost(@AuthUser User user, + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @RequestParam(value = "is-closed", required = false) Boolean isClosed) { + return recruitmentPostService.getManagementPost(size, page, user, isClosed, ManageType.BOOKMARKED); + } + + @GetMapping("/applied") + @Override + public PaginationDto getAppliedPost(@AuthUser User user, + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @RequestParam(value = "is-closed", required = false) Boolean isClosed) { + return recruitmentPostService.getManagementPost(size, page, user, isClosed, ManageType.APPLIED); + } + + @GetMapping("/my-post") + @Override + public PaginationDto getMyPost(@AuthUser User user, + @RequestParam(value = "size", required = false, defaultValue = "24") @Valid @Min(1) int size, + @RequestParam(value = "page", required = false, defaultValue = "1") @Valid @Min(1) int page, + @RequestParam(value = "is-closed", required = false) Boolean isClosed) { + return recruitmentPostService.getManagementPost(size, page, user, isClosed, ManageType.WRITTEN); + } +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostApi.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostApi.java index 43d49933..0846c565 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostApi.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostApi.java @@ -108,6 +108,17 @@ ResponseEntity applyRecruitment(@PathVariable("id") Long postId, ResponseEntity modifyRecruitmentPost(@Valid @RequestBody CreateRecruitmentPostRequestDto requestDto, @PathVariable("id") Long postId, @AuthUser User user); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "구인글 삭제 성공" + , content = { + @Content(mediaType = APPLICATION_JSON_VALUE) + }) + } + ) + @Operation(summary = "구인글 삭제 API", tags = {"recruitment"}) + ResponseEntity deleteRecruitmentPost(@PathVariable("id") Long postId, @AuthUser User user); + @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "북마크 성공" diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostController.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostController.java index 9cb5206b..c5c5c5a9 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostController.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/api/RecruitmentPostController.java @@ -60,8 +60,8 @@ import synk.meeteam.domain.user.user.service.UserService; import synk.meeteam.global.entity.Category; import synk.meeteam.global.entity.Scope; -import synk.meeteam.infra.s3.S3FileName; -import synk.meeteam.infra.s3.service.S3Service; +import synk.meeteam.infra.aws.S3FilePath; +import synk.meeteam.infra.aws.service.CloudFrontService; import synk.meeteam.security.AuthUser; @@ -87,7 +87,7 @@ public class RecruitmentPostController implements RecruitmentPostApi { private final CourseService courseService; private final ProfessorService professorService; - private final S3Service s3Service; + private final CloudFrontService cloudFrontService; private final RecruitmentPostMapper recruitmentPostMapper; @@ -133,7 +133,7 @@ public ResponseEntity getRecruitmentPost( } User writer = userService.findById(recruitmentPost.getCreatedBy()); - String writerImgUrl = s3Service.createPreSignedGetUrl(S3FileName.USER, writer.getProfileImgFileName()); + String writerImgUrl = cloudFrontService.getSignedUrl(S3FilePath.USER, writer.getProfileImgFileName()); List recruitmentRoles = recruitmentRoleService.findByRecruitmentPostId(postId); @@ -146,7 +146,7 @@ public ResponseEntity getRecruitmentPost( return ResponseEntity.ok() .body(GetRecruitmentPostResponseDto.from(recruitmentPost, isApplied, isBookmarked, recruitmentRoles, - writer, + writer, user, writerImgUrl, recruitmentTags, recruitmentCommentDtos, recruitmentPost.getCourse(), recruitmentPost.getProfessor())); @@ -160,7 +160,7 @@ public ResponseEntity getApplyInfo(@PathVariable("id") postId); return ResponseEntity.ok() - .body(new GetApplyInfoResponseDto(user.getName(), user.getEvaluationScore(), + .body(new GetApplyInfoResponseDto(user.getName(), user.getGpa(), user.getUniversity().getName(), user.getDepartment().getName(), user.getAdmissionYear(), user.getUniversityEmail(), availableRecruitmentRoleDtos)); @@ -177,8 +177,9 @@ public ResponseEntity applyRecruitment(@PathVariable("id") Long postId, RecruitmentApplicant recruitmentApplicant = recruitmentPostMapper.toRecruitmentApplicantEntity( recruitmentRole.getRecruitmentPost(), recruitmentRole.getRole(), user, requestDto.message(), RecruitStatus.NONE); - - recruitmentPostFacade.applyRecruitment(recruitmentRole, recruitmentApplicant); + RecruitmentPost recruitmentPost = recruitmentPostService.getRecruitmentPost(postId); + User writer = userService.findById(recruitmentPost.getCreatedBy()); + recruitmentPostFacade.applyRecruitment(recruitmentRole, recruitmentApplicant, writer); return ResponseEntity.ok().build(); } @@ -226,7 +227,15 @@ public ResponseEntity modifyRecruitmentPost(@Valid @RequestBody CreateRecr List recruitmentTags = getRecruitmentTags(requestDto, dstRecruitmentPost); recruitmentPostFacade.modifyRecruitmentPost(dstRecruitmentPost, srcRecruitmentPost, recruitmentRoles, - recruitmentRoleSkills, recruitmentTags); + recruitmentRoleSkills, recruitmentTags, user.getId()); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{id}") + @Override + public ResponseEntity deleteRecruitmentPost(@PathVariable("id") Long postId, @AuthUser User user) { + recruitmentPostService.deleteRecruitmentPost(postId, user.getId()); return ResponseEntity.ok().build(); } @@ -354,10 +363,10 @@ private SearchCondition filterCondition(Long fieldId, Integer scopeOrdinal, Inte Long professorId) { Scope scope = null; Category category = null; - if (scopeOrdinal != null && scopeOrdinal > 1 && scopeOrdinal < Scope.values().length) { + if (scopeOrdinal != null && scopeOrdinal >= 1 && scopeOrdinal <= Scope.values().length) { scope = Scope.values()[scopeOrdinal - 1]; } - if (categoryOrdinal != null && categoryOrdinal > 1 && categoryOrdinal < Category.values().length) { + if (categoryOrdinal != null && categoryOrdinal >= 1 && categoryOrdinal <= Category.values().length) { category = Category.values()[categoryOrdinal - 1]; } return new SearchCondition(fieldId, scope, category, skillIds, tagIds, roleIds, courseId, professorId); diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/ManageType.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/ManageType.java new file mode 100644 index 00000000..a72f3941 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/ManageType.java @@ -0,0 +1,5 @@ +package synk.meeteam.domain.recruitment.recruitment_post.dto; + +public enum ManageType { + APPLIED, BOOKMARKED, WRITTEN +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/SearchRecruitmentPostMapper.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/SimpleRecruitmentPostMapper.java similarity index 55% rename from src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/SearchRecruitmentPostMapper.java rename to src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/SimpleRecruitmentPostMapper.java index b8a071e7..467bc42a 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/SearchRecruitmentPostMapper.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/SimpleRecruitmentPostMapper.java @@ -2,12 +2,15 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SearchRecruitmentPostDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SimpleRecruitmentPostDto; import synk.meeteam.domain.recruitment.recruitment_post.repository.vo.RecruitmentPostVo; @Mapper(componentModel = "spring") -public interface SearchRecruitmentPostMapper { +public interface SimpleRecruitmentPostMapper { @Mapping(target = "category", expression = "java(recruitmentPostVo.getCategory().getName())") @Mapping(target = "scope", expression = "java(recruitmentPostVo.getScope().getName())") - SearchRecruitmentPostDto toSearchRecruitmentPostDto(RecruitmentPostVo recruitmentPostVo); + @Mapping(target = "writerProfileImg", source = "writerProfileImg") + @Mapping(target = "writerId", source = "writerId") + SimpleRecruitmentPostDto toSimpleRecruitmentPostDto(RecruitmentPostVo recruitmentPostVo, String writerId, + String writerProfileImg); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CourseTagDto.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CourseTagDto.java index 8227fb2d..6283c3c4 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CourseTagDto.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CourseTagDto.java @@ -2,14 +2,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; public record CourseTagDto( @NotNull @Schema(description = "수업 여부", example = "true") Boolean isCourse, @Schema(description = "수업 관련 태그 이름", example = "응용소프트웨어실습 or null") + @Size(max = 20) String courseTagName, @Schema(description = "교수명", example = "김용혁 or null") + @Size(max = 20) String courseProfessor ) { } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CreateRecruitmentPostRequestDto.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CreateRecruitmentPostRequestDto.java index 7ba0ece8..e6be6e68 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CreateRecruitmentPostRequestDto.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/request/CreateRecruitmentPostRequestDto.java @@ -36,9 +36,11 @@ public record CreateRecruitmentPostRequestDto( CourseTagDto courseTag, @NotNull @Schema(description = "태그", example = "[\"웹개발\", \"AI\", \"졸업작품\"]") + @Size(max = 5) List tags, @NotNull @Schema(description = "필요한 역할들(List 형태로)", example = "") + @Size(max = 10) List recruitmentRoles, @NotBlank @Size(min = 5, max = 40) diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java index 71198ccb..5497ba1d 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java @@ -1,5 +1,6 @@ package synk.meeteam.domain.recruitment.recruitment_post.dto.response; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Builder; @@ -10,6 +11,7 @@ import synk.meeteam.domain.recruitment.recruitment_role.entity.RecruitmentRole; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.util.Encryption; +import synk.meeteam.global.util.UnescapedFieldSerializer; @Builder @Schema(name = "GetRecruitmentPostResponseDto", description = "구인글 조회 Dto") @@ -59,6 +61,7 @@ public record GetRecruitmentPostResponseDto( @Schema(description = "구인 역할", example = "") List recruitmentRoles, @Schema(description = "상세 내용", example = "안녕하세요. 저는 팀원을...") + @JsonSerialize(using = UnescapedFieldSerializer.class) String content, @Schema(description = "댓글, 대댓글", example = "") List comments @@ -66,12 +69,15 @@ public record GetRecruitmentPostResponseDto( ) { public static GetRecruitmentPostResponseDto from(RecruitmentPost recruitmentPost, boolean isApplied, boolean isBookmarked, - List recruitmentRoles, User writer, + List recruitmentRoles, User writer, User user, String writerProfileImg, List recruitmentTags, List recruitmentCommentDtos, Course course, Professor professor) { - boolean isWriter = writer.getId().equals(recruitmentPost.getCreatedBy()); + boolean isWriter = false; + if(user != null){ + isWriter = user.getId().equals(writer.getId()); + } List getRecruitmentRoleDtos = recruitmentRoles.stream() .map(GetRecruitmentRoleResponseDto::from) .toList(); diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/PaginationSearchPostResponseDto.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/PaginationSearchPostResponseDto.java index 3af8d879..d1bed228 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/PaginationSearchPostResponseDto.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/PaginationSearchPostResponseDto.java @@ -4,7 +4,7 @@ import synk.meeteam.global.dto.PageInfo; public record PaginationSearchPostResponseDto( - List posts, + List posts, PageInfo pageInfo ) { } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/SearchRecruitmentPostDto.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/SimpleRecruitmentPostDto.java similarity index 77% rename from src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/SearchRecruitmentPostDto.java rename to src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/SimpleRecruitmentPostDto.java index 9c4fd8a2..aa8494f3 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/SearchRecruitmentPostDto.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/SimpleRecruitmentPostDto.java @@ -2,13 +2,15 @@ import io.swagger.v3.oas.annotations.media.Schema; -public record SearchRecruitmentPostDto( +public record SimpleRecruitmentPostDto( @Schema(description = "글 id", example = "1") Long id, @Schema(description = "글 제목", example = "팀원을 구합니다!") String title, @Schema(description = "유형", example = "프로젝트") String category, + @Schema(description = "작성자 id", example = "sdfkljwncxmv") + String writerId, @Schema(description = "작성자 닉네임", example = "song123") String writerNickname, @Schema(description = "작성자 사진", example = "url 형태") @@ -18,6 +20,8 @@ public record SearchRecruitmentPostDto( @Schema(description = "범위", example = "교내") String scope, @Schema(description = "북마크 여부", example = "true") - Boolean isBookmarked + Boolean isBookmarked, + @Schema(description = "마감 여부", example = "true") + Boolean isClosed ) { } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/entity/RecruitmentPost.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/entity/RecruitmentPost.java index 296ec96c..21a47072 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/entity/RecruitmentPost.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/entity/RecruitmentPost.java @@ -32,6 +32,7 @@ import synk.meeteam.domain.recruitment.recruitment_post.exception.RecruitmentPostException; import synk.meeteam.global.entity.BaseEntity; import synk.meeteam.global.entity.Category; +import synk.meeteam.global.entity.DeleteStatus; import synk.meeteam.global.entity.ProceedType; import synk.meeteam.global.entity.Scope; @@ -121,11 +122,17 @@ public class RecruitmentPost extends BaseEntity { @JoinColumn(name = "professor_id") private Professor professor; + @NotNull + @Enumerated(EnumType.STRING) + @ColumnDefault("'ALIVE'") + private DeleteStatus deleteStatus = DeleteStatus.ALIVE; + @Builder public RecruitmentPost(String title, String content, Scope scope, Category category, Field field, ProceedType proceedType, LocalDate proceedingStart, LocalDate proceedingEnd, LocalDate deadline, - long bookmarkCount, String kakaoLink, boolean isClosed, Meeteam meeteam) { + long bookmarkCount, String kakaoLink, boolean isClosed, Meeteam meeteam, + Course course, Professor professor) { this.title = title; this.content = content; this.scope = scope; @@ -139,6 +146,8 @@ public RecruitmentPost(String title, String content, Scope scope, Category categ this.kakaoLink = kakaoLink; this.isClosed = isClosed; this.meeteam = meeteam; + this.course = course; + this.professor = professor; } public double getResponseRate() { @@ -176,7 +185,9 @@ public void updateRecruitmentPost(String title, String content, Scope scope, Cat LocalDate deadline, long bookmarkCount, String kakaoLink, boolean isClosed, Meeteam meeteam, long applicantCount, - long responseCount) { + long responseCount, Long userId) { + validateWriter(userId); + this.title = title; this.content = content; this.scope = scope; @@ -194,6 +205,11 @@ public void updateRecruitmentPost(String title, String content, Scope scope, Cat this.responseCount = responseCount; } + public void softDelete(Long userId) { + validateWriter(userId); + this.deleteStatus = DeleteStatus.DELETED; + } + public RecruitmentPost setLink(String kakaoLink, Long userId) { validateWriter(userId); diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/facade/RecruitmentPostFacade.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/facade/RecruitmentPostFacade.java index 3460e542..22f492ec 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/facade/RecruitmentPostFacade.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/facade/RecruitmentPostFacade.java @@ -6,8 +6,6 @@ import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.common.course.entity.Course; import synk.meeteam.domain.common.course.entity.Professor; -import synk.meeteam.domain.common.course.service.CourseService; -import synk.meeteam.domain.common.course.service.ProfessorService; import synk.meeteam.domain.recruitment.bookmark.service.BookmarkService; import synk.meeteam.domain.recruitment.recruitment_applicant.entity.RecruitmentApplicant; import synk.meeteam.domain.recruitment.recruitment_applicant.service.RecruitmentApplicantService; @@ -20,6 +18,7 @@ import synk.meeteam.domain.recruitment.recruitment_tag.entity.RecruitmentTag; import synk.meeteam.domain.recruitment.recruitment_tag.service.RecruitmentTagService; import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.infra.mail.MailService; @Service @@ -29,11 +28,10 @@ public class RecruitmentPostFacade { private final RecruitmentRoleService recruitmentRoleService; private final RecruitmentRoleSkillService recruitmentRoleSkillService; private final RecruitmentTagService recruitmentTagService; - private final CourseService courseService; - private final ProfessorService professorService; private final RecruitmentApplicantService recruitmentApplicantService; private final BookmarkService bookmarkService; + private final MailService mailService; @Transactional public Long createRecruitmentPost(RecruitmentPost recruitmentPost, List recruitmentRoles, @@ -43,8 +41,6 @@ public Long createRecruitmentPost(RecruitmentPost recruitmentPost, List recruitmentRoles, List recruitmentRoleSkills, - List recruitmentTags) { + List recruitmentTags, Long userId) { - recruitmentPostService.modifyRecruitmentPost(dstRecruitmentPost, srcRecruitmentPost); + recruitmentPostService.modifyRecruitmentPost(dstRecruitmentPost, srcRecruitmentPost, userId); // cascade 설정을 하여 recruitmentRoleService에서 Role과 Skills를 한 번에 삭제한다. recruitmentRoleService.modifyRecruitmentRoleAndSkills(recruitmentRoles, recruitmentRoleSkills, @@ -65,12 +61,16 @@ public void modifyRecruitmentPost(RecruitmentPost dstRecruitmentPost, Recruitmen } @Transactional - public void applyRecruitment(RecruitmentRole recruitmentRole, RecruitmentApplicant recruitmentApplicant) { + public void applyRecruitment(RecruitmentRole recruitmentRole, RecruitmentApplicant recruitmentApplicant, + User writer) { recruitmentApplicantService.registerRecruitmentApplicant(recruitmentApplicant); recruitmentPostService.incrementApplicantCount(recruitmentApplicant.getRecruitmentPost()); recruitmentRoleService.incrementApplicantCount(recruitmentRole); + + mailService.sendApplicationNotificationMail(recruitmentApplicant.getRecruitmentPost().getId(), + recruitmentApplicant, writer); } @Transactional diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentCustomRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentCustomRepository.java new file mode 100644 index 00000000..69eafad5 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentCustomRepository.java @@ -0,0 +1,5 @@ +package synk.meeteam.domain.recruitment.recruitment_post.repository; + +public interface RecruitmentCustomRepository { + void updateIsCloseTrue(); +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentCustomRepositoryImpl.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentCustomRepositoryImpl.java new file mode 100644 index 00000000..2b630750 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentCustomRepositoryImpl.java @@ -0,0 +1,25 @@ +package synk.meeteam.domain.recruitment.recruitment_post.repository; + +import static synk.meeteam.domain.recruitment.recruitment_post.entity.QRecruitmentPost.recruitmentPost; + +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RecruitmentCustomRepositoryImpl implements RecruitmentCustomRepository { + private final JPAQueryFactory queryFactory; + + @Override + public void updateIsCloseTrue() { + queryFactory.update(recruitmentPost) + .where(Expressions.asDate(LocalDate.now(ZoneId.of("Asia/Seoul"))).after(recruitmentPost.deadline)) + .set(recruitmentPost.isClosed, true) + .execute(); + } + +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentManagementRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentManagementRepository.java new file mode 100644 index 00000000..eadbe3a9 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentManagementRepository.java @@ -0,0 +1,11 @@ +package synk.meeteam.domain.recruitment.recruitment_post.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import synk.meeteam.domain.recruitment.recruitment_post.dto.ManageType; +import synk.meeteam.domain.recruitment.recruitment_post.repository.vo.RecruitmentPostVo; +import synk.meeteam.domain.user.user.entity.User; + +public interface RecruitmentManagementRepository { + Page findManagementPost(Pageable pageable, User user, Boolean isClosed, ManageType manageType); +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentManagementRepositoryImpl.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentManagementRepositoryImpl.java new file mode 100644 index 00000000..33485481 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentManagementRepositoryImpl.java @@ -0,0 +1,108 @@ +package synk.meeteam.domain.recruitment.recruitment_post.repository; + +import static synk.meeteam.domain.recruitment.bookmark.entity.QBookmark.bookmark; +import static synk.meeteam.domain.recruitment.recruitment_applicant.entity.QRecruitmentApplicant.recruitmentApplicant; +import static synk.meeteam.domain.recruitment.recruitment_post.entity.QRecruitmentPost.recruitmentPost; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.RecruitmentManagementRepositoryImpl.managementJpaUtils.joinByManagementType; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.isClosedEq; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.isNotDeleted; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.isWriter; + +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Repository; +import synk.meeteam.domain.recruitment.recruitment_post.dto.ManageType; +import synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils; +import synk.meeteam.domain.recruitment.recruitment_post.repository.vo.QRecruitmentPostVo; +import synk.meeteam.domain.recruitment.recruitment_post.repository.vo.RecruitmentPostVo; +import synk.meeteam.domain.user.user.entity.QUser; +import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.entity.DeleteStatus; + +@Repository +@RequiredArgsConstructor +public class RecruitmentManagementRepositoryImpl implements RecruitmentManagementRepository { + private final JPAQueryFactory queryFactory; + + public Page findManagementPost(Pageable pageable, User user, Boolean isClosed, + ManageType manageType) { + List contents = getManagementPostVos(pageable, user, isClosed, manageType); + JPAQuery countQuery = getManagementPostCount(user, isClosed, manageType); + return PageableExecutionUtils.getPage(contents, pageable, countQuery::fetchOne); + } + + private List getManagementPostVos(Pageable pageable, User userDomain, Boolean isClosed, + ManageType manageType) { + QUser writer = new QUser("writer"); + + JPAQuery query = queryFactory.select( + new QRecruitmentPostVo( + recruitmentPost.id, + recruitmentPost.title, + recruitmentPost.category, + recruitmentPost.scope, + writer.id, + writer.nickname, + writer.profileImgFileName, + recruitmentPost.deadline, + manageType == ManageType.BOOKMARKED ? Expressions.asBoolean(true) + : RecruitmentExpressionUtils.isBookmark(userDomain), + recruitmentPost.createdAt, + recruitmentPost.isClosed + ) + ) + .distinct() + .from(recruitmentPost) + .leftJoin(writer).on(recruitmentPost.createdBy.eq(writer.id)) + .where( + isClosedEq(isClosed), + isNotDeleted(), + isWriter(manageType, userDomain, writer) + ); + + joinByManagementType(userDomain, manageType, query); + + return query.orderBy(recruitmentPost.createdAt.desc(), recruitmentPost.id.desc()) + .offset(pageable.getOffset()) //페이지 번호 + .limit(pageable.getPageSize()) //페이지 사이즈 + .fetch(); + } + + private JPAQuery getManagementPostCount(User userDomain, Boolean isClosed, ManageType manageType) { + QUser writer = new QUser("writer"); + + JPAQuery query = queryFactory.select(recruitmentPost.countDistinct()) + .from(recruitmentPost) + .leftJoin(writer).on(recruitmentPost.createdBy.eq(writer.id)) + .leftJoin(bookmark).on(recruitmentPost.id.eq(bookmark.recruitmentPost.id)) + .where( + isClosedEq(isClosed), + isNotDeleted(), + isWriter(manageType, userDomain, writer) + ); + joinByManagementType(userDomain, manageType, query); + + return query; + } + + static class managementJpaUtils { + static void joinByManagementType(User userDomain, ManageType manageType, JPAQuery query) { + if (manageType.equals(ManageType.BOOKMARKED)) { + query.leftJoin(bookmark).on(recruitmentPost.id.eq(bookmark.recruitmentPost.id)) + .where(bookmark.user.id.eq(userDomain.getId())); + } else if (manageType.equals(ManageType.APPLIED)) { + query.leftJoin(recruitmentApplicant) + .on(recruitmentPost.id.eq(recruitmentApplicant.recruitmentPost.id)) + .where(recruitmentApplicant.applicant.id.eq(userDomain.getId()), + recruitmentApplicant.deleteStatus.ne(DeleteStatus.DELETED)); + } + } + } + +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostRepository.java index 73c0ade9..f08205e8 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostRepository.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostRepository.java @@ -2,14 +2,30 @@ import static synk.meeteam.domain.recruitment.recruitment_post.exception.RecruitmentPostExceptionType.NOT_FOUND_POST; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.recruitment.recruitment_post.exception.RecruitmentPostException; -public interface RecruitmentPostRepository extends JpaRepository, - RecruitmentPostSearchRepository { +public interface RecruitmentPostRepository extends JpaRepository, RecruitmentCustomRepository, + RecruitmentPostSearchRepository, RecruitmentManagementRepository { + + @Query("SELECT r FROM RecruitmentPost r WHERE r.id = :id AND r.deleteStatus = synk.meeteam.global.entity.DeleteStatus.ALIVE") + Optional findByIdAndDeleteStatus(@Param("id") Long postId); default RecruitmentPost findByIdOrElseThrow(Long postId) { - return findById(postId).orElseThrow(() -> new RecruitmentPostException(NOT_FOUND_POST)); + return findByIdAndDeleteStatus(postId).orElseThrow(() -> new RecruitmentPostException(NOT_FOUND_POST)); } + + List findAllByCreatedBy(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM RecruitmentPost r WHERE r.id IN :postIds") + void deleteAllByIdInQuery(List postIds); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostSearchRepositoryImpl.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostSearchRepositoryImpl.java index 5c6feaa1..2b0b01c9 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostSearchRepositoryImpl.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/RecruitmentPostSearchRepositoryImpl.java @@ -1,20 +1,21 @@ package synk.meeteam.domain.recruitment.recruitment_post.repository; -import static com.querydsl.jpa.JPAExpressions.selectOne; import static synk.meeteam.domain.common.course.entity.QCourse.course; import static synk.meeteam.domain.common.course.entity.QProfessor.professor; import static synk.meeteam.domain.common.field.entity.QField.field; import static synk.meeteam.domain.common.role.entity.QRole.role; import static synk.meeteam.domain.common.skill.entity.QSkill.skill; import static synk.meeteam.domain.common.tag.entity.QTag.tag; -import static synk.meeteam.domain.recruitment.bookmark.entity.QBookmark.bookmark; import static synk.meeteam.domain.recruitment.recruitment_post.entity.QRecruitmentPost.recruitmentPost; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.categoryEq; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.isBookmark; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.scopeEq; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.titleContains; +import static synk.meeteam.domain.recruitment.recruitment_post.repository.expression.RecruitmentExpressionUtils.writerUniversityEqUser; import static synk.meeteam.domain.recruitment.recruitment_role.entity.QRecruitmentRole.recruitmentRole; import static synk.meeteam.domain.recruitment.recruitment_role_skill.entity.QRecruitmentRoleSkill.recruitmentRoleSkill; import static synk.meeteam.domain.recruitment.recruitment_tag.entity.QRecruitmentTag.recruitmentTag; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; @@ -29,7 +30,7 @@ import synk.meeteam.domain.recruitment.recruitment_post.repository.vo.RecruitmentPostVo; import synk.meeteam.domain.user.user.entity.QUser; import synk.meeteam.domain.user.user.entity.User; -import synk.meeteam.global.entity.Category; +import synk.meeteam.global.entity.DeleteStatus; import synk.meeteam.global.entity.Scope; @Repository @@ -57,12 +58,14 @@ private List getPostVos(Pageable pageable, SearchCondition co recruitmentPost.id, recruitmentPost.title, recruitmentPost.category, + recruitmentPost.scope, + writer.id, writer.nickname, writer.profileImgFileName, recruitmentPost.deadline, - recruitmentPost.scope, isBookmark(userDomain), - recruitmentPost.createdAt + recruitmentPost.createdAt, + recruitmentPost.isClosed ) ) .distinct() @@ -70,19 +73,21 @@ private List getPostVos(Pageable pageable, SearchCondition co .leftJoin(writer).on(recruitmentPost.createdBy.eq(writer.id)) .where( scopeEq(condition.getScope()), - writerUniversityEq(writer, userDomain, condition.getScope()), + userDomain != null ? writerUniversityEqUser(writer, userDomain, condition.getScope()) : null, categoryEq(condition.getCategory()), - titleStartWith(keyword) + titleContains(keyword), + recruitmentPost.deleteStatus.ne(DeleteStatus.DELETED), + recruitmentPost.isClosed.isFalse() ); - query = jpaUtils.joinWithFieldAndTagAndRoleAndSkill(query, condition); + searchJpaUtils.joinWithFieldAndTagAndRoleAndSkill(query, condition); //교내 if (condition.getScope() == Scope.ON_CAMPUS) { - query = jpaUtils.joinWithCourseAndProfessor(query, condition, userDomain); + searchJpaUtils.joinWithCourseAndProfessor(query, condition, userDomain); } - return query.orderBy(recruitmentPost.createdAt.desc()) + return query.orderBy(recruitmentPost.createdAt.desc(), recruitmentPost.id.desc()) .offset(pageable.getOffset()) //페이지 번호 .limit(pageable.getPageSize()) //페이지 사이즈 .fetch(); @@ -96,71 +101,34 @@ private JPAQuery getCount(SearchCondition condition, String keyword, User .leftJoin(writer).on(recruitmentPost.createdBy.eq(writer.id)) .where( scopeEq(condition.getScope()), - writerUniversityEq(writer, userDomain, condition.getScope()), + userDomain != null ? writerUniversityEqUser(writer, userDomain, condition.getScope()) : null, categoryEq(condition.getCategory()), - titleStartWith(keyword) + titleContains(keyword), + recruitmentPost.deleteStatus.ne(DeleteStatus.DELETED), + recruitmentPost.isClosed.isFalse() ); - countQuery = jpaUtils.joinWithFieldAndTagAndRoleAndSkill(countQuery, condition); + searchJpaUtils.joinWithFieldAndTagAndRoleAndSkill(countQuery, condition); //교내 if (condition.getScope() == Scope.ON_CAMPUS) { - countQuery = jpaUtils.joinWithCourseAndProfessor(countQuery, condition, userDomain); + searchJpaUtils.joinWithCourseAndProfessor(countQuery, condition, userDomain); } return countQuery; } - //BooleanExpression - private BooleanExpression isBookmark(User userDomain) { - //로그인 안된 경우 - if (userDomain == null) { - return isFalse(); - } - - return selectOne() - .from(bookmark) - .where( - bookmark.user.id.eq(userDomain.getId()), - bookmark.recruitmentPost.id.eq(recruitmentPost.id)) - .exists(); - } - - private BooleanExpression writerUniversityEq(QUser writer, User userDomain, Scope scope) { - return scope != Scope.ON_CAMPUS ? null : writer.university.eq(userDomain.getUniversity()); - } - - private BooleanExpression categoryEq(Category category) { - return category == null ? null : recruitmentPost.category.eq(category); - } - - private BooleanExpression scopeEq(Scope scope) { - if (scope == null) { - return null; - } else { - return recruitmentPost.scope.eq(scope); - } - } - - private BooleanExpression titleStartWith(String keyword) { - return (keyword == null || keyword.isEmpty()) ? null : recruitmentPost.title.startsWith(keyword); - } - - private BooleanExpression isFalse() { - return Expressions.asBoolean(false); - } - - static class jpaUtils { - public static JPAQuery joinWithFieldAndTagAndRoleAndSkill(JPAQuery query, SearchCondition condition) { + static class searchJpaUtils { + public static void joinWithFieldAndTagAndRoleAndSkill(JPAQuery query, SearchCondition condition) { //분야 if (condition.isExistField()) { - query = query.leftJoin(recruitmentPost.field, field) + query.leftJoin(recruitmentPost.field, field) .where(field.id.eq(condition.getFieldId())); } //태그 if (condition.isExistTags()) { - query = query.leftJoin(recruitmentTag).on(recruitmentPost.id.eq(recruitmentTag.recruitmentPost.id)) + query.leftJoin(recruitmentTag).on(recruitmentPost.id.eq(recruitmentTag.recruitmentPost.id)) .leftJoin(recruitmentTag.tag, tag) .where( tag.type.eq(TagType.MEETEAM), @@ -170,46 +138,43 @@ public static JPAQuery joinWithFieldAndTagAndRoleAndSkill(JPAQuery que //역할 if (condition.isExistRoles()) { - query = query.leftJoin(recruitmentRole) + query.leftJoin(recruitmentRole) .on(recruitmentPost.id.eq(recruitmentRole.recruitmentPost.id)) .leftJoin(recruitmentRole.role, role) .where(role.id.in(condition.getRoleIds())); //역할 + 스킬 if (condition.isExistSkills()) { - query = query.leftJoin(recruitmentRoleSkill) + query.leftJoin(recruitmentRoleSkill) .on(recruitmentRole.id.eq(recruitmentRoleSkill.recruitmentRole.id)) .leftJoin(recruitmentRoleSkill.skill, skill) .where(skill.id.in(condition.getSkillIds())); } } else if (condition.isExistSkills()) { - query = query.leftJoin(recruitmentRole) + query.leftJoin(recruitmentRole) .on(recruitmentPost.id.eq(recruitmentRole.recruitmentPost.id)) .leftJoin(recruitmentRoleSkill) .on(recruitmentRole.id.eq(recruitmentRoleSkill.recruitmentRole.id)) .leftJoin(recruitmentRoleSkill.skill, skill) .where(skill.id.in(condition.getSkillIds())); } - - return query; } - public static JPAQuery joinWithCourseAndProfessor(JPAQuery query, - SearchCondition condition, User userDomain) { + public static void joinWithCourseAndProfessor(JPAQuery query, + SearchCondition condition, User userDomain) { if (condition.isExistCourse()) { - query = query.leftJoin(recruitmentPost.course, course) + query.leftJoin(recruitmentPost.course, course) .where( course.id.eq(condition.getCourseId()), course.university.eq(userDomain.getUniversity()) ); } if (condition.isExistProfessor()) { - query = query.leftJoin(recruitmentPost.professor, professor) + query.leftJoin(recruitmentPost.professor, professor) .where( professor.id.eq(condition.getProfessorId()), professor.university.eq(userDomain.getUniversity()) ); } - return query; } } } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/expression/RecruitmentExpressionUtils.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/expression/RecruitmentExpressionUtils.java new file mode 100644 index 00000000..41eb0d0e --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/expression/RecruitmentExpressionUtils.java @@ -0,0 +1,81 @@ +package synk.meeteam.domain.recruitment.recruitment_post.repository.expression; + +import static com.querydsl.jpa.JPAExpressions.selectOne; +import static synk.meeteam.domain.recruitment.bookmark.entity.QBookmark.bookmark; +import static synk.meeteam.domain.recruitment.recruitment_post.entity.QRecruitmentPost.recruitmentPost; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import synk.meeteam.domain.recruitment.recruitment_post.dto.ManageType; +import synk.meeteam.domain.user.user.entity.QUser; +import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.global.entity.Category; +import synk.meeteam.global.entity.DeleteStatus; +import synk.meeteam.global.entity.Scope; + +public class RecruitmentExpressionUtils { + public static BooleanExpression isBookmark(User userDomain) { + //로그인 안된 경우 + if (userDomain == null) { + return isFalse(); + } + + return selectOne() + .from(bookmark) + .where( + bookmark.user.id.eq(userDomain.getId()), + bookmark.recruitmentPost.id.eq(recruitmentPost.id)) + .exists(); + } + + public static BooleanExpression isClosedEq(Boolean isClosed) { + return isClosed == null ? null : recruitmentPost.isClosed.eq(isClosed); + } + + public static BooleanExpression writerUniversityEqUser(QUser writer, User userDomain, Scope scope) { + if (scope == Scope.ON_CAMPUS) { + return isOnCampus(writer, userDomain); + } else if (scope == Scope.OFF_CAMPUS) { + return isOffCampus(); + } else { + return isOnCampus(writer, userDomain).or(isOffCampus()); + } + } + + public static BooleanExpression isOffCampus() { + return recruitmentPost.scope.eq(Scope.OFF_CAMPUS); + } + + public static BooleanExpression isOnCampus(QUser writer, User userDomain) { + return recruitmentPost.scope.eq(Scope.ON_CAMPUS).and(writer.university.eq(userDomain.getUniversity())); + } + + public static BooleanExpression categoryEq(Category category) { + return category == null ? null : recruitmentPost.category.eq(category); + } + + public static BooleanExpression scopeEq(Scope scope) { + if (scope == null) { + return null; + } else { + return recruitmentPost.scope.eq(scope); + } + } + + public static BooleanExpression titleContains(String keyword) { + return (keyword == null || keyword.isEmpty()) ? null : recruitmentPost.title.contains(keyword); + } + + private static BooleanExpression isFalse() { + return Expressions.asBoolean(false); + } + + public static BooleanExpression isWriter(ManageType manageType, User user, QUser writer) { + return manageType.equals(ManageType.WRITTEN) ? writer.id.eq(user.getId()) : null; + } + + public static BooleanExpression isNotDeleted() { + return recruitmentPost.deleteStatus.ne(DeleteStatus.DELETED); + } + +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/vo/RecruitmentPostVo.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/vo/RecruitmentPostVo.java index 4ca6e5fc..c565afa4 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/vo/RecruitmentPostVo.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/repository/vo/RecruitmentPostVo.java @@ -14,24 +14,30 @@ public class RecruitmentPostVo { private String title; private Category category; private Scope scope; + private Long writerId; private String writerNickname; private String writerProfileImg; private LocalDate deadline; private Boolean isBookmarked; private LocalDateTime createdAt; + private Boolean isClosed; @Builder @QueryProjection - public RecruitmentPostVo(Long id, String title, Category category, String writerNickname, String writerProfileImg, - LocalDate deadline, Scope scope, Boolean isBookmarked, LocalDateTime createdAt) { + public RecruitmentPostVo(Long id, String title, Category category, Scope scope, Long writerId, + String writerNickname, + String writerProfileImg, LocalDate deadline, Boolean isBookmarked, LocalDateTime createdAt, + Boolean isClosed) { this.id = id; this.title = title; this.category = category; + this.scope = scope; + this.writerId = writerId; this.writerNickname = writerNickname; this.writerProfileImg = writerProfileImg; this.deadline = deadline; - this.scope = scope; this.isBookmarked = isBookmarked; this.createdAt = createdAt; + this.isClosed = isClosed; } } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/service/RecruitmentClosingService.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/service/RecruitmentClosingService.java new file mode 100644 index 00000000..759615c7 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/service/RecruitmentClosingService.java @@ -0,0 +1,24 @@ +package synk.meeteam.domain.recruitment.recruitment_post.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import synk.meeteam.domain.recruitment.recruitment_post.repository.RecruitmentPostRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RecruitmentClosingService { + + private final RecruitmentPostRepository recruitmentPostRepository; + + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void closing() { + log.info("Start Auto Recruitment Closing Process"); + recruitmentPostRepository.updateIsCloseTrue(); + log.info("End Auto Recruitment Closing Process"); + } +} diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/service/RecruitmentPostService.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/service/RecruitmentPostService.java index e5568ea6..7a878d23 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/service/RecruitmentPostService.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/service/RecruitmentPostService.java @@ -6,23 +6,29 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import synk.meeteam.domain.recruitment.recruitment_post.dto.ManageType; import synk.meeteam.domain.recruitment.recruitment_post.dto.SearchCondition; -import synk.meeteam.domain.recruitment.recruitment_post.dto.SearchRecruitmentPostMapper; +import synk.meeteam.domain.recruitment.recruitment_post.dto.SimpleRecruitmentPostMapper; import synk.meeteam.domain.recruitment.recruitment_post.dto.response.PaginationSearchPostResponseDto; -import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SearchRecruitmentPostDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SimpleRecruitmentPostDto; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.recruitment.recruitment_post.repository.RecruitmentPostRepository; import synk.meeteam.domain.recruitment.recruitment_post.repository.vo.RecruitmentPostVo; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.dto.PageInfo; +import synk.meeteam.global.dto.PaginationDto; import synk.meeteam.global.entity.Scope; +import synk.meeteam.global.util.Encryption; +import synk.meeteam.infra.aws.S3FilePath; +import synk.meeteam.infra.aws.service.CloudFrontService; @Service @RequiredArgsConstructor public class RecruitmentPostService { private final RecruitmentPostRepository recruitmentPostRepository; - private final SearchRecruitmentPostMapper searchRecruitmentPostMapper; + private final SimpleRecruitmentPostMapper simpleRecruitmentPostMapper; + private final CloudFrontService cloudFrontService; @Transactional public RecruitmentPost writeRecruitmentPost(RecruitmentPost recruitmentPost) { @@ -34,6 +40,12 @@ public RecruitmentPost getRecruitmentPost(final Long postId) { return recruitmentPostRepository.findByIdOrElseThrow(postId); } + @Transactional + public void deleteRecruitmentPost(final Long postId, final Long userId) { + RecruitmentPost recruitmentPost = recruitmentPostRepository.findByIdOrElseThrow(postId); + recruitmentPost.softDelete(userId); + } + @Transactional public void incrementApplicantCount(RecruitmentPost recruitmentPost) { recruitmentPost.incrementApplicantCount(); @@ -65,7 +77,7 @@ public RecruitmentPost decrementBookmarkCount(RecruitmentPost recruitmentPost) { @Transactional public RecruitmentPost modifyRecruitmentPost(RecruitmentPost dstRecruitmentPost, - RecruitmentPost srcRecruitmentPost) { + RecruitmentPost srcRecruitmentPost, Long userId) { dstRecruitmentPost.updateRecruitmentPost(srcRecruitmentPost.getTitle(), srcRecruitmentPost.getContent(), srcRecruitmentPost.getScope(), srcRecruitmentPost.getCategory(), srcRecruitmentPost.getField(), @@ -73,7 +85,7 @@ public RecruitmentPost modifyRecruitmentPost(RecruitmentPost dstRecruitmentPost, srcRecruitmentPost.getProceedingEnd(), srcRecruitmentPost.getDeadline(), srcRecruitmentPost.getBookmarkCount(), srcRecruitmentPost.getKakaoLink(), srcRecruitmentPost.isClosed(), srcRecruitmentPost.getMeeteam(), - srcRecruitmentPost.getApplicantCount(), srcRecruitmentPost.getResponseCount()); + srcRecruitmentPost.getApplicantCount(), srcRecruitmentPost.getResponseCount(), userId); return recruitmentPostRepository.save(dstRecruitmentPost); } @@ -91,8 +103,12 @@ public PaginationSearchPostResponseDto searchWithPageRecruitmentPost(int size, i Page postVos = recruitmentPostRepository .findBySearchConditionAndKeyword(PageRequest.of(page - 1, size), condition, keyword, user); PageInfo pageInfo = new PageInfo(page, size, postVos.getTotalElements(), postVos.getTotalPages()); - List contents = postVos.stream() - .map(searchRecruitmentPostMapper::toSearchRecruitmentPostDto).toList(); + List contents = postVos.stream() + .map((postVo) -> { + String writerEncryptedId = Encryption.encryptLong(postVo.getWriterId()); + String imageUrl = cloudFrontService.getSignedUrl(S3FilePath.USER, postVo.getWriterProfileImg()); + return simpleRecruitmentPostMapper.toSimpleRecruitmentPostDto(postVo, writerEncryptedId, imageUrl); + }).toList(); return new PaginationSearchPostResponseDto(contents, pageInfo); } @@ -112,4 +128,19 @@ public void incrementResponseCount(Long postId, Long userId, long responseCount) RecruitmentPost recruitmentPost = recruitmentPostRepository.findByIdOrElseThrow(postId); recruitmentPost.incrementResponseCount(userId, responseCount); } + + @Transactional + public PaginationDto getManagementPost(int size, int page, User user, Boolean isClosed, + ManageType manageType) { + Page postVos = recruitmentPostRepository.findManagementPost(PageRequest.of(page - 1, size), + user, isClosed, manageType); + PageInfo pageInfo = new PageInfo(page, size, postVos.getTotalElements(), postVos.getTotalPages()); + List contents = postVos.stream() + .map((postVo) -> { + String writerEncryptedId = Encryption.encryptLong(postVo.getWriterId()); + String imageUrl = cloudFrontService.getSignedUrl(S3FilePath.USER, postVo.getWriterProfileImg()); + return simpleRecruitmentPostMapper.toSimpleRecruitmentPostDto(postVo, writerEncryptedId, imageUrl); + }).toList(); + return new PaginationDto<>(contents, pageInfo); + } } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/repository/RecruitmentRoleRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/repository/RecruitmentRoleRepository.java index 5d44d109..e229a6f1 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/repository/RecruitmentRoleRepository.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/repository/RecruitmentRoleRepository.java @@ -6,8 +6,10 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.common.role.entity.Role; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.recruitment.recruitment_role.entity.RecruitmentRole; @@ -15,7 +17,7 @@ public interface RecruitmentRoleRepository extends JpaRepository, RecruitmentRoleRepositoryCustom { - @Query("SELECT r FROM RecruitmentRole r JOIN FETCH r.recruitmentRoleSkills s JOIN FETCH s.skill WHERE r.recruitmentPost.id = :postId") + @Query("SELECT r FROM RecruitmentRole r LEFT JOIN FETCH r.recruitmentRoleSkills s LEFT JOIN FETCH s.skill WHERE r.recruitmentPost.id = :postId") List findByPostIdWithSkills(@Param("postId") Long postId); @Query("SELECT r FROM RecruitmentRole r JOIN FETCH r.recruitmentPost p JOIN FETCH r.role WHERE p.id = :postId") @@ -31,6 +33,12 @@ default RecruitmentRole findByIdWithRecruitmentRoleAndRoleOrElseThrow(Long recru void deleteAllByRecruitmentPostId(Long postId); + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM RecruitmentRole r WHERE r.recruitmentPost.id IN :postIds") + void deleteAllByPostIdInQuery(List postIds); + + @Query("SELECT r FROM RecruitmentRole r JOIN FETCH r.recruitmentPost p JOIN FETCH r.role t WHERE p.id = :postId AND t.id IN :roleIds") List findAllByPostIdAndRoleIds(@Param("postId") Long postId, @Param("roleIds") List roleIds); diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/service/RecruitmentRoleService.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/service/RecruitmentRoleService.java index 33517e93..613d4d64 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/service/RecruitmentRoleService.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_role/service/RecruitmentRoleService.java @@ -1,11 +1,14 @@ package synk.meeteam.domain.recruitment.recruitment_role.service; +import static synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantExceptionType.INVALID_USER; + import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.common.role.entity.Role; +import synk.meeteam.domain.recruitment.recruitment_applicant.exception.RecruitmentApplicantException; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.recruitment.recruitment_role.dto.AvailableRecruitmentRoleDto; import synk.meeteam.domain.recruitment.recruitment_role.entity.RecruitmentRole; @@ -63,7 +66,9 @@ public void modifyRecruitmentRoleAndSkills(List recruitmentRole } @Transactional(readOnly = true) - public List findApplyStatusRecruitmentRole(Long postId) { + public List findApplyStatusRecruitmentRole(Long postId, Long userId, Long writerId) { + validateIsWriter(userId, writerId); + return recruitmentRoleRepository.findAllByPostIdWithRecruitmentRoleAndRole(postId); } @@ -78,4 +83,10 @@ public void incrementRecruitedCount(Long postId, Long userId, List roleIds }); } + private void validateIsWriter(Long userId, Long writerId) { + + if (!userId.equals(writerId)) { + throw new RecruitmentApplicantException(INVALID_USER); + } + } } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_role_skill/repository/RecruitmentRoleSkillRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_role_skill/repository/RecruitmentRoleSkillRepository.java index e87d60d8..e085b14a 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_role_skill/repository/RecruitmentRoleSkillRepository.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_role_skill/repository/RecruitmentRoleSkillRepository.java @@ -11,7 +11,7 @@ public interface RecruitmentRoleSkillRepository extends JpaRepository { @Transactional - @Modifying + @Modifying(clearAutomatically = true) @Query("delete from RecruitmentRoleSkill r where r.recruitmentRole.id in :recruitmentRoleIds") void deleteAllByRecruitmentRoleIdInQuery(@Param("recruitmentRoleIds") List recruitmentRoleIds); } diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_tag/repository/RecruitmentTagRepository.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_tag/repository/RecruitmentTagRepository.java index 73908028..10963ebd 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_tag/repository/RecruitmentTagRepository.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_tag/repository/RecruitmentTagRepository.java @@ -2,8 +2,10 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.recruitment.recruitment_tag.entity.RecruitmentTag; public interface RecruitmentTagRepository extends JpaRepository { @@ -11,4 +13,9 @@ public interface RecruitmentTagRepository extends JpaRepository findByPostIdWithTag(@Param("postId") Long postId); void deleteAllByRecruitmentPostId(Long postId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM RecruitmentTag r WHERE r.recruitmentPost.id IN :postIds") + void deleteAllByPostIdInQuery(List postIds); } diff --git a/src/main/java/synk/meeteam/domain/user/award/dto/UpdateAwardDto.java b/src/main/java/synk/meeteam/domain/user/award/dto/UpdateAwardDto.java index 403a820f..bba5fc0a 100644 --- a/src/main/java/synk/meeteam/domain/user/award/dto/UpdateAwardDto.java +++ b/src/main/java/synk/meeteam/domain/user/award/dto/UpdateAwardDto.java @@ -1,13 +1,19 @@ package synk.meeteam.domain.user.award.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.LocalDate; @Schema(name = "UpdateAwardDto", description = "수상/활동 내역 Dto") public record UpdateAwardDto( @Schema(description = "제목", example = "2022 공공데이터 활용 공모전") + @NotNull + @Size(max = 20) String title, @Schema(description = "부연설명", example = "장려상 수상") + @NotNull + @Size(max = 20) String description, @Schema(description = "기간 시작일", example = "2022-08") diff --git a/src/main/java/synk/meeteam/domain/user/award/repository/AwardRepository.java b/src/main/java/synk/meeteam/domain/user/award/repository/AwardRepository.java index da1a663c..3bfcfed1 100644 --- a/src/main/java/synk/meeteam/domain/user/award/repository/AwardRepository.java +++ b/src/main/java/synk/meeteam/domain/user/award/repository/AwardRepository.java @@ -2,6 +2,9 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.user.award.entity.Award; public interface AwardRepository extends JpaRepository { @@ -9,4 +12,9 @@ public interface AwardRepository extends JpaRepository { void deleteAllByCreatedBy(Long userId); List findAllByCreatedBy(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM Award a WHERE a.createdBy = :userId") + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/synk/meeteam/domain/user/user/api/UserApi.java b/src/main/java/synk/meeteam/domain/user/user/api/UserApi.java index d858601e..747ab9a5 100644 --- a/src/main/java/synk/meeteam/domain/user/user/api/UserApi.java +++ b/src/main/java/synk/meeteam/domain/user/user/api/UserApi.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -15,6 +16,7 @@ import synk.meeteam.domain.portfolio.portfolio.dto.response.GetUserPortfolioResponseDto; import synk.meeteam.domain.user.user.dto.request.UpdateProfileRequestDto; import synk.meeteam.domain.user.user.dto.response.CheckDuplicateNicknameResponseDto; +import synk.meeteam.domain.user.user.dto.response.GetProfileImageResponseDto; import synk.meeteam.domain.user.user.dto.response.GetProfileResponseDto; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.common.exception.ExceptionResponse; @@ -31,7 +33,7 @@ public interface UserApi { ) @Operation(summary = "유저 프로필 저장 API") @SecurityRequirement(name = "Authorization") - ResponseEntity editProfile(@AuthUser User user, @RequestBody UpdateProfileRequestDto requestDto); + ResponseEntity editProfile(@AuthUser User user, @RequestBody @Valid UpdateProfileRequestDto requestDto); @ApiResponses( value = { @@ -43,7 +45,7 @@ public interface UserApi { ) @Operation(summary = "유저 프로필 조회 API") @SecurityRequirements - ResponseEntity getProfile(@PathVariable("userId") String userId); + ResponseEntity getProfile(@AuthUser User user, @PathVariable("userId") String userId); @ApiResponses( @@ -65,4 +67,12 @@ public interface UserApi { ResponseEntity getUserPortfolio(@AuthUser User user, @RequestParam(name = "page", defaultValue = "1") int page, @RequestParam(name = "size", defaultValue = "12") int size); + + @Operation(summary = "내 프로필 사진 조회 API") + @SecurityRequirement(name = "Authorization") + ResponseEntity getProfileImage(@AuthUser User user); + + @Operation(summary = "회원 탈퇴 API") + @SecurityRequirement(name = "Authorization") + ResponseEntity deleteUser(@AuthUser User user); } diff --git a/src/main/java/synk/meeteam/domain/user/user/api/UserController.java b/src/main/java/synk/meeteam/domain/user/user/api/UserController.java index ee8a51f5..ce7fded0 100644 --- a/src/main/java/synk/meeteam/domain/user/user/api/UserController.java +++ b/src/main/java/synk/meeteam/domain/user/user/api/UserController.java @@ -1,8 +1,11 @@ package synk.meeteam.domain.user.user.api; +import static synk.meeteam.infra.aws.S3FilePath.USER; + import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -14,11 +17,14 @@ import synk.meeteam.domain.portfolio.portfolio.service.PortfolioService; import synk.meeteam.domain.user.user.dto.request.UpdateProfileRequestDto; import synk.meeteam.domain.user.user.dto.response.CheckDuplicateNicknameResponseDto; +import synk.meeteam.domain.user.user.dto.response.GetProfileImageResponseDto; import synk.meeteam.domain.user.user.dto.response.GetProfileResponseDto; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.domain.user.user.service.ProfileFacade; +import synk.meeteam.domain.user.user.service.UserManagementService; import synk.meeteam.domain.user.user.service.UserService; import synk.meeteam.global.util.Encryption; +import synk.meeteam.infra.aws.service.CloudFrontService; import synk.meeteam.security.AuthUser; @RestController @@ -28,12 +34,15 @@ public class UserController implements UserApi { private final UserService userService; private final PortfolioService portfolioService; + private final CloudFrontService cloudFrontService; + private final ProfileFacade profileFacade; + private final UserManagementService userManagementService; @Override @PutMapping("/profile") public ResponseEntity editProfile(@AuthUser User user, - @Valid @RequestBody UpdateProfileRequestDto requestDto) { + @RequestBody @Valid UpdateProfileRequestDto requestDto) { profileFacade.editProfile(user, requestDto); @@ -42,11 +51,12 @@ public ResponseEntity editProfile(@AuthUser User user, @Override @GetMapping("/profile/{userId}") - public ResponseEntity getProfile(@PathVariable("userId") String userId) { - return ResponseEntity.ok(profileFacade.readProfile(userId)); + public ResponseEntity getProfile(@AuthUser User user, + @PathVariable("userId") String userId) { + return ResponseEntity.ok(profileFacade.readProfile(user, userId)); } - @GetMapping("encrypt/{userId}") + @GetMapping("/encrypt/{userId}") public ResponseEntity getEncryptedId(@PathVariable("userId") Long userId) { return ResponseEntity.ok(Encryption.encryptLong(userId)); } @@ -66,6 +76,21 @@ public ResponseEntity checkDuplicateNickname( public ResponseEntity getUserPortfolio( @AuthUser User user, @RequestParam(name = "page", defaultValue = "1") int page, @RequestParam(name = "size", defaultValue = "12") int size) { - return ResponseEntity.ok(portfolioService.getMyAllPortfolio(page, size, user)); + return ResponseEntity.ok(portfolioService.getSliceMyAllPortfolio(page, size, user)); + } + + @Override + @GetMapping("/profile/image") + public ResponseEntity getProfileImage(@AuthUser User user) { + String profileImgUrl = cloudFrontService.getSignedUrl(USER, user.getProfileImgFileName()); + return ResponseEntity.ok(GetProfileImageResponseDto.of(profileImgUrl)); + } + + @Override + @DeleteMapping + public ResponseEntity deleteUser(@AuthUser User user) { + userManagementService.deleteUser(user); + + return ResponseEntity.ok(null); } } \ No newline at end of file diff --git a/src/main/java/synk/meeteam/domain/user/user/dto/ProfileMapper.java b/src/main/java/synk/meeteam/domain/user/user/dto/ProfileMapper.java index 88139a7a..4c81b7ee 100644 --- a/src/main/java/synk/meeteam/domain/user/user/dto/ProfileMapper.java +++ b/src/main/java/synk/meeteam/domain/user/user/dto/ProfileMapper.java @@ -6,53 +6,51 @@ import org.mapstruct.Named; import org.mapstruct.ReportingPolicy; import synk.meeteam.domain.common.skill.dto.SkillDto; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.user.award.dto.GetProfileAwardDto; import synk.meeteam.domain.user.user.dto.response.GetProfileEmailDto; import synk.meeteam.domain.user.user.dto.response.GetProfilePhoneDto; import synk.meeteam.domain.user.user.dto.response.GetProfileResponseDto; -import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.domain.user.user.dto.response.ProfileDto; import synk.meeteam.domain.user.user_link.dto.GetProfileUserLinkDto; @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR) public interface ProfileMapper { - @Mapping(source = "user", target = "universityEmail", qualifiedByName = "toUniversityEmail") - @Mapping(source = "user", target = "subEmail", qualifiedByName = "toSubEmail") - @Mapping(source = "user", target = "phone", qualifiedByName = "toPhone") - @Mapping(source = "user.interestRole.name", target = "interest") - @Mapping(source = "user.name", target = "userName") - @Mapping(source = "user.isPublicName", target = "isUserNamePublic") - @Mapping(source = "user.oneLineIntroduction", target = "introduction") - @Mapping(source = "user.mainIntroduction", target = "aboutMe") - @Mapping(source = "user.admissionYear", target = "year") - @Mapping(source = "user.university.name", target = "university") - @Mapping(source = "user.department.name", target = "department") + @Mapping(source = "profile", target = "universityEmail", qualifiedByName = "toUniversityEmail") + @Mapping(source = "profile", target = "subEmail", qualifiedByName = "toSubEmail") + @Mapping(source = "profile", target = "phone", qualifiedByName = "toPhone") + @Mapping(source = "profile.name", target = "userName") + @Mapping(source = "profile.isPublicName", target = "isUserNamePublic") + @Mapping(source = "profile.oneLineIntroduction", target = "introduction") + @Mapping(source = "profile.mainIntroduction", target = "aboutMe") + @Mapping(source = "profile.admissionYear", target = "year") + @Mapping(source = "profile.profileImgFileName", target = "imageFileName") GetProfileResponseDto toGetProfileResponseDto( - User user, + ProfileDto profile, String imageUrl, List links, List awards, - List portfolios, + List portfolios, List skills ); @Named("toUniversityEmail") - @Mapping(source = "user.universityEmail", target = "content") - @Mapping(source = "user.isPublicUniversityEmail", target = "isPublic") - @Mapping(source = "user.isUniversityMainEmail", target = "isDefault") - GetProfileEmailDto toUniversityEmail(User user); + @Mapping(source = "profile.universityEmail", target = "content") + @Mapping(source = "profile.isPublicUniversityEmail", target = "isPublic") + @Mapping(source = "profile.isUniversityMainEmail", target = "isDefault") + GetProfileEmailDto toUniversityEmail(ProfileDto profile); @Named("toSubEmail") @Mapping(source = "subEmail", target = "content") @Mapping(source = "isPublicSubEmail", target = "isPublic") - @Mapping(expression = "java(!user.isUniversityMainEmail())", target = "isDefault") - GetProfileEmailDto toSubEmail(User user); + @Mapping(expression = "java(!profile.isUniversityMainEmail())", target = "isDefault") + GetProfileEmailDto toSubEmail(ProfileDto profile); @Named("toPhone") @Mapping(source = "phoneNumber", target = "content") @Mapping(source = "isPublicPhone", target = "isPublic") - GetProfilePhoneDto tuPhone(User user); + GetProfilePhoneDto tuPhone(ProfileDto profile); } diff --git a/src/main/java/synk/meeteam/domain/user/user/dto/request/UpdateProfileRequestDto.java b/src/main/java/synk/meeteam/domain/user/user/dto/request/UpdateProfileRequestDto.java index a891b4d8..273a6a1c 100644 --- a/src/main/java/synk/meeteam/domain/user/user/dto/request/UpdateProfileRequestDto.java +++ b/src/main/java/synk/meeteam/domain/user/user/dto/request/UpdateProfileRequestDto.java @@ -2,10 +2,12 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; +import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.util.List; import synk.meeteam.domain.user.award.dto.UpdateAwardDto; import synk.meeteam.domain.user.user_link.dto.UpdateUserLinkDto; @@ -22,7 +24,8 @@ public record UpdateProfileRequestDto( @NotNull boolean isUserNamePublic, - @Schema(description = "프로필사진 url") + @Schema(description = "프로필 이미지 파일명") + @Pattern(regexp = "^(\\S+(\\.(?i)(jpg|png|gif|bmp|jpeg))$)") String imageFileName, @Schema(description = "전화정보", example = "010-1234-5678") @@ -65,15 +68,22 @@ public record UpdateProfileRequestDto( Double maxGpa, @Schema(description = "스킬 id 정보", example = "[1,2,3]") + @NotNull + @Size(max = 10) List skills, @Schema(description = "링크 목록") - List links, + @NotNull + @Size(max = 10) + List<@Valid UpdateUserLinkDto> links, @Schema(description = "수상내역") - List awards, + @NotNull + @Size(max = 10) + List<@Valid UpdateAwardDto> awards, @Schema(description = "포트폴리오 순서", example = "[1,2,3]") + @Size(max = 8) List portfolios ) { diff --git a/src/main/java/synk/meeteam/domain/user/user/dto/response/GetProfileImageResponseDto.java b/src/main/java/synk/meeteam/domain/user/user/dto/response/GetProfileImageResponseDto.java new file mode 100644 index 00000000..b37c43ed --- /dev/null +++ b/src/main/java/synk/meeteam/domain/user/user/dto/response/GetProfileImageResponseDto.java @@ -0,0 +1,12 @@ +package synk.meeteam.domain.user.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GetProfileImageResponseDto( + @Schema(description = "프로필 이미지 url", example = "https://image.png") + String imageUrl +) { + public static GetProfileImageResponseDto of(String imageUrl) { + return new GetProfileImageResponseDto(imageUrl); + } +} diff --git a/src/main/java/synk/meeteam/domain/user/user/dto/response/GetProfileResponseDto.java b/src/main/java/synk/meeteam/domain/user/user/dto/response/GetProfileResponseDto.java index dd92147a..c571c63a 100644 --- a/src/main/java/synk/meeteam/domain/user/user/dto/response/GetProfileResponseDto.java +++ b/src/main/java/synk/meeteam/domain/user/user/dto/response/GetProfileResponseDto.java @@ -1,15 +1,19 @@ package synk.meeteam.domain.user.user.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; import java.util.List; import synk.meeteam.domain.common.skill.dto.SkillDto; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.user.award.dto.GetProfileAwardDto; import synk.meeteam.domain.user.user_link.dto.GetProfileUserLinkDto; public record GetProfileResponseDto( @Schema(description = "프로필 이미지 url", example = "https://image.png") String imageUrl, + @Schema(description = "프로필 이미지 파일명", example = "dfksfdfs.png") + @Pattern(regexp = "^(\\S+(\\.(?i)(jpg|png|gif|bmp|jpeg))$)") + String imageFileName, @Schema(description = "이름", example = "민지") String userName, @Schema(description = "닉네임", example = "minji") @@ -35,7 +39,7 @@ public record GetProfileResponseDto( Double gpa, @Schema(description = "입학년도", example = "2019") int year, - List portfolios, + List portfolios, List links, List awards, List skills diff --git a/src/main/java/synk/meeteam/domain/user/user/dto/response/ProfileDto.java b/src/main/java/synk/meeteam/domain/user/user/dto/response/ProfileDto.java new file mode 100644 index 00000000..cc0b2bc0 --- /dev/null +++ b/src/main/java/synk/meeteam/domain/user/user/dto/response/ProfileDto.java @@ -0,0 +1,24 @@ +package synk.meeteam.domain.user.user.dto.response; + +public record ProfileDto( + String profileImgFileName, + String name, + boolean isPublicName, + String nickname, + String interest, + String oneLineIntroduction, + String mainIntroduction, + boolean isUniversityMainEmail, + String universityEmail, + boolean isPublicUniversityEmail, + String subEmail, + boolean isPublicSubEmail, + String phoneNumber, + boolean isPublicPhone, + String university, + String department, + Double maxGpa, + Double gpa, + int admissionYear +) { +} diff --git a/src/main/java/synk/meeteam/domain/user/user/entity/User.java b/src/main/java/synk/meeteam/domain/user/user/entity/User.java index 7bf635df..ba6106a7 100644 --- a/src/main/java/synk/meeteam/domain/user/user/entity/User.java +++ b/src/main/java/synk/meeteam/domain/user/user/entity/User.java @@ -25,6 +25,7 @@ import synk.meeteam.domain.common.department.entity.Department; import synk.meeteam.domain.common.role.entity.Role; import synk.meeteam.domain.common.university.entity.University; +import synk.meeteam.domain.user.user.dto.response.ProfileDto; import synk.meeteam.domain.user.user.entity.enums.Authority; import synk.meeteam.domain.user.user.entity.enums.PlatformType; import synk.meeteam.global.entity.BaseTimeEntity; @@ -156,6 +157,12 @@ public class User extends BaseTimeEntity { @ColumnDefault("0") private long scoreProfessionalism = 0; + @ColumnDefault("1") + private boolean isFirstProfileAccess = true; + + @ColumnDefault("1") + private boolean isFirstApplicantAccess = true; + public User(String platformId) { this.platformId = platformId; } @@ -238,6 +245,49 @@ public void passwordEncode(PasswordEncoder passwordEncoder) { this.password = passwordEncoder.encode(this.password); } + public ProfileDto getOpenProfile(boolean isNotWriter) { + String openName = name; + String openPhoneNumber = phoneNumber; + String openUniversityEmail = universityEmail; + String openSubEmail = subEmail; + if (isNotWriter) { + if (!isPublicName) { + openName = null; + } + if (!isPublicPhone) { + openPhoneNumber = null; + } + if (!isPublicUniversityEmail) { + openUniversityEmail = null; + } + if (!isPublicSubEmail) { + openSubEmail = null; + } + } + String interest = (interestRole == null) ? null : interestRole.getName(); + + return new ProfileDto( + profileImgFileName, + openName, + isPublicName, + nickname, + interest, + oneLineIntroduction, + mainIntroduction, + isUniversityMainEmail, + openUniversityEmail, + isPublicUniversityEmail, + openSubEmail, + isPublicSubEmail, + openPhoneNumber, + isPublicPhone, + university.getName(), + department.getName(), + maxGpa, + gpa, + admissionYear + ); + } //닉네임 변경 public void updateNickname(String nickname) { @@ -247,4 +297,12 @@ public void updateNickname(String nickname) { public boolean isNotEqualNickname(String nickname) { return !this.nickname.equals(nickname); } + + public void processFirstAccess() { + this.isFirstApplicantAccess = false; + } + + public String getMainEmail(){ + return isUniversityMainEmail ? universityEmail : subEmail; + } } diff --git a/src/main/java/synk/meeteam/domain/user/user/repository/UserRepository.java b/src/main/java/synk/meeteam/domain/user/user/repository/UserRepository.java index 47960550..538964e5 100644 --- a/src/main/java/synk/meeteam/domain/user/user/repository/UserRepository.java +++ b/src/main/java/synk/meeteam/domain/user/user/repository/UserRepository.java @@ -31,5 +31,6 @@ default User findByPlatformIdAndPlatformTypeOrElseThrowException(String platform () -> new AuthException(NOT_FOUND_USER)); } + Optional findByUniversityEmail(String email); } diff --git a/src/main/java/synk/meeteam/domain/user/user/service/ProfileFacade.java b/src/main/java/synk/meeteam/domain/user/user/service/ProfileFacade.java index 8c13d39f..e0f0241c 100644 --- a/src/main/java/synk/meeteam/domain/user/user/service/ProfileFacade.java +++ b/src/main/java/synk/meeteam/domain/user/user/service/ProfileFacade.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.common.skill.dto.SkillDto; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.portfolio.portfolio.entity.PortfolioMapper; import synk.meeteam.domain.portfolio.portfolio.service.PortfolioService; @@ -17,14 +17,16 @@ import synk.meeteam.domain.user.user.dto.UpdateProfileCommandMapper; import synk.meeteam.domain.user.user.dto.request.UpdateProfileRequestDto; import synk.meeteam.domain.user.user.dto.response.GetProfileResponseDto; +import synk.meeteam.domain.user.user.dto.response.ProfileDto; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.domain.user.user_link.dto.GetProfileUserLinkDto; import synk.meeteam.domain.user.user_link.entity.UserLink; import synk.meeteam.domain.user.user_link.entity.UserLinkMapper; import synk.meeteam.domain.user.user_link.service.UserLinkService; import synk.meeteam.domain.user.user_skill.service.UserSkillService; -import synk.meeteam.infra.s3.S3FileName; -import synk.meeteam.infra.s3.service.S3Service; +import synk.meeteam.global.util.Encryption; +import synk.meeteam.infra.aws.S3FilePath; +import synk.meeteam.infra.aws.service.CloudFrontService; @Service @RequiredArgsConstructor @@ -35,7 +37,7 @@ public class ProfileFacade { private final UserSkillService userSkillService; private final PortfolioService portfolioService; private final AwardService awardService; - private final S3Service s3Service; + private final CloudFrontService cloudFrontService; private final UpdateProfileCommandMapper updateProfileCommandMapper; @@ -66,14 +68,15 @@ public void editProfile(User user, UpdateProfileRequestDto profileDto) { } @Transactional(readOnly = true) - public GetProfileResponseDto readProfile(String encryptedId) { - User user = userService.findByEncryptedId(encryptedId); - String profileImgUrl = s3Service.createPreSignedGetUrl(S3FileName.USER, user.getProfileImgFileName()); - List links = getProfileLinks(user.getId()); - List awards = getProfileAwards(user.getId()); - List portfolios = getProfilePortfolios(user.getId(), encryptedId); - List skills = userSkillService.getUserSKill(user.getId()); - return profileMapper.toGetProfileResponseDto(user, profileImgUrl, links, awards, + public GetProfileResponseDto readProfile(User user, String encryptedId) { + Long userId = Encryption.decryptLong(encryptedId); + ProfileDto openProfile = userService.getOpenProfile(userId, user); + String profileImgUrl = cloudFrontService.getSignedUrl(S3FilePath.USER, openProfile.profileImgFileName()); + List links = getProfileLinks(userId); + List awards = getProfileAwards(userId); + List portfolios = getProfilePortfolios(userId, encryptedId); + List skills = userSkillService.getUserSKill(userId); + return profileMapper.toGetProfileResponseDto(openProfile, profileImgUrl, links, awards, portfolios, skills); } @@ -87,12 +90,12 @@ private List getProfileAwards(Long userId) { return awards.stream().map(awardMapper::toGetProfileAwardDto).toList(); } - private List getProfilePortfolios(Long userId, String encryptedId) { + private List getProfilePortfolios(Long userId, String encryptedId) { List portfolios = portfolioService.getMyPinPortfolio(userId); return portfolios.stream().map((portfolio) -> { - String imageUrl = s3Service.createPreSignedGetUrl( - S3FileName.getPortfolioUrl(encryptedId), + String imageUrl = cloudFrontService.getSignedUrl( + S3FilePath.getPortfolioPath(encryptedId), portfolio.getMainImageFileName()); return portfolioMapper.toGetProfilePortfolioDto(portfolio, imageUrl); }).toList(); diff --git a/src/main/java/synk/meeteam/domain/user/user/service/UserManagementService.java b/src/main/java/synk/meeteam/domain/user/user/service/UserManagementService.java new file mode 100644 index 00000000..7c8c84fd --- /dev/null +++ b/src/main/java/synk/meeteam/domain/user/user/service/UserManagementService.java @@ -0,0 +1,92 @@ +package synk.meeteam.domain.user.user.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; +import synk.meeteam.domain.portfolio.portfolio.repository.PortfolioRepository; +import synk.meeteam.domain.portfolio.portfolio_link.repository.PortfolioLinkRepository; +import synk.meeteam.domain.portfolio.portfolio_skill.repository.PortfolioSkillRepository; +import synk.meeteam.domain.recruitment.bookmark.repository.BookmarkRepository; +import synk.meeteam.domain.recruitment.recruitment_applicant.repository.RecruitmentApplicantRepository; +import synk.meeteam.domain.recruitment.recruitment_comment.repository.RecruitmentCommentRepository; +import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; +import synk.meeteam.domain.recruitment.recruitment_post.repository.RecruitmentPostRepository; +import synk.meeteam.domain.recruitment.recruitment_role.repository.RecruitmentRoleRepository; +import synk.meeteam.domain.recruitment.recruitment_role_skill.repository.RecruitmentRoleSkillRepository; +import synk.meeteam.domain.recruitment.recruitment_tag.repository.RecruitmentTagRepository; +import synk.meeteam.domain.user.award.repository.AwardRepository; +import synk.meeteam.domain.user.user.entity.User; +import synk.meeteam.domain.user.user.repository.UserRepository; +import synk.meeteam.domain.user.user_link.repository.UserLinkRepository; +import synk.meeteam.domain.user.user_skill.repository.UserSkillRepository; + +@Service +@RequiredArgsConstructor +public class UserManagementService { + + // 유저 관련 + private final UserRepository userRepository; + private final UserSkillRepository userSkillRepository; + private final UserLinkRepository userLinkRepository; + private final AwardRepository awardRepository; + + // 포토폴리오 관련 + private final PortfolioRepository portfolioRepository; + private final PortfolioSkillRepository portfolioSkillRepository; + private final PortfolioLinkRepository portfolioLinkRepository; + + // 구인 관련 + private final RecruitmentPostRepository recruitmentPostRepository; + private final RecruitmentRoleRepository recruitmentRoleRepository; + private final RecruitmentRoleSkillRepository recruitmentRoleSkillRepository; + private final RecruitmentTagRepository recruitmentTagRepository; + private final RecruitmentCommentRepository recruitmentCommentRepository; + private final RecruitmentApplicantRepository recruitmentApplicantRepository; + + // 기타 + private final BookmarkRepository bookmarkRepository; + + @Transactional + public void deleteUser(User user) { + List postIds = recruitmentPostRepository.findAllByCreatedBy(user.getId()).stream() + .map(RecruitmentPost::getId).toList(); + deleteRecruitmentPosts(postIds); + + List portfolioIds = portfolioRepository.findAllByCreatedBy(user.getId()) + .stream().map(Portfolio::getId).toList(); + deletePortfolios(portfolioIds); + + deleteProfile(user.getId()); + deleteRelatedUserData(user.getId()); + + userRepository.delete(user); + } + + private void deleteRecruitmentPosts(List postIds) { + bookmarkRepository.deleteAllByPostIdInQuery(postIds); + recruitmentRoleRepository.deleteAllByPostIdInQuery(postIds); + recruitmentTagRepository.deleteAllByPostIdInQuery(postIds); + recruitmentCommentRepository.deleteAllByPostIdInQuery(postIds); + recruitmentApplicantRepository.deleteAllByPostIdInQuery(postIds); + recruitmentPostRepository.deleteAllByIdInQuery(postIds); + } + + private void deletePortfolios(List portfolioIds) { + portfolioSkillRepository.deleteAllByPortfolioIdsInQuery(portfolioIds); + portfolioLinkRepository.deleteAllByPortfolioIdsInQuery(portfolioIds); + portfolioRepository.deleteAllByIdsInQuery(portfolioIds); + } + + private void deleteProfile(Long userId) { + userSkillRepository.deleteAllByUserId(userId); + userLinkRepository.deleteAllByUserId(userId); + awardRepository.deleteAllByUserId(userId); + } + + private void deleteRelatedUserData(Long userId) { + bookmarkRepository.deleteAllByUserId(userId); + recruitmentApplicantRepository.deleteAllByUserId(userId); + } +} diff --git a/src/main/java/synk/meeteam/domain/user/user/service/UserService.java b/src/main/java/synk/meeteam/domain/user/user/service/UserService.java index b35fd9dc..c9c3c0a6 100644 --- a/src/main/java/synk/meeteam/domain/user/user/service/UserService.java +++ b/src/main/java/synk/meeteam/domain/user/user/service/UserService.java @@ -1,6 +1,7 @@ package synk.meeteam.domain.user.user.service; import synk.meeteam.domain.user.user.dto.command.UpdateInfoCommand; +import synk.meeteam.domain.user.user.dto.response.ProfileDto; import synk.meeteam.domain.user.user.entity.User; public interface UserService { @@ -13,4 +14,8 @@ public interface UserService { User findById(Long userId); User findByEncryptedId(String encryptedId); + + ProfileDto getOpenProfile(Long userId, User reader); + + void processFirstAccess(User user); } diff --git a/src/main/java/synk/meeteam/domain/user/user/service/UserServiceImpl.java b/src/main/java/synk/meeteam/domain/user/user/service/UserServiceImpl.java index a162cdd9..7ac0da35 100644 --- a/src/main/java/synk/meeteam/domain/user/user/service/UserServiceImpl.java +++ b/src/main/java/synk/meeteam/domain/user/user/service/UserServiceImpl.java @@ -9,6 +9,7 @@ import synk.meeteam.domain.common.role.entity.Role; import synk.meeteam.domain.common.role.repository.RoleRepository; import synk.meeteam.domain.user.user.dto.command.UpdateInfoCommand; +import synk.meeteam.domain.user.user.dto.response.ProfileDto; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.domain.user.user.exception.UserException; import synk.meeteam.domain.user.user.repository.UserRepository; @@ -74,4 +75,20 @@ public User findByEncryptedId(String encryptedId) { } return userRepository.findByIdFetchRole(userId); } + + @Override + public ProfileDto getOpenProfile(Long userId, User reader) { + if (userId == null) { + throw new UserException(NOT_FOUND_USER); + } + User user = userRepository.findByIdFetchRole(userId); + boolean isNotWriter = reader == null || !userId.equals(reader.getId()); + return user.getOpenProfile(isNotWriter); + } + + @Transactional + public void processFirstAccess(User user) { + user.processFirstAccess(); + userRepository.save(user); + } } diff --git a/src/main/java/synk/meeteam/domain/user/user_link/dto/UpdateUserLinkDto.java b/src/main/java/synk/meeteam/domain/user/user_link/dto/UpdateUserLinkDto.java index 52b48a71..1bc66d8f 100644 --- a/src/main/java/synk/meeteam/domain/user/user_link/dto/UpdateUserLinkDto.java +++ b/src/main/java/synk/meeteam/domain/user/user_link/dto/UpdateUserLinkDto.java @@ -1,12 +1,16 @@ package synk.meeteam.domain.user.user_link.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; @Schema(name = "SaveUserLinkDto", description = "유저 링크 Dto") public record UpdateUserLinkDto( @Schema(description = "url", example = "naver.com") + @Pattern(regexp = "https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#()?&/=]*)") String url, @Schema(description = "부연설명", example = "네이버") + @Size(max = 20) String description ) { diff --git a/src/main/java/synk/meeteam/domain/user/user_link/repository/UserLinkRepository.java b/src/main/java/synk/meeteam/domain/user/user_link/repository/UserLinkRepository.java index 641fcd46..00842cb0 100644 --- a/src/main/java/synk/meeteam/domain/user/user_link/repository/UserLinkRepository.java +++ b/src/main/java/synk/meeteam/domain/user/user_link/repository/UserLinkRepository.java @@ -2,6 +2,9 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.user.user_link.entity.UserLink; public interface UserLinkRepository extends JpaRepository { @@ -9,4 +12,9 @@ public interface UserLinkRepository extends JpaRepository { void deleteAllByCreatedBy(Long userId); List findAllByCreatedBy(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM UserLink u WHERE u.createdBy = :userId") + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/synk/meeteam/domain/user/user_skill/repository/UserSkillRepository.java b/src/main/java/synk/meeteam/domain/user/user_skill/repository/UserSkillRepository.java index c16baa52..ae7df2c1 100644 --- a/src/main/java/synk/meeteam/domain/user/user_skill/repository/UserSkillRepository.java +++ b/src/main/java/synk/meeteam/domain/user/user_skill/repository/UserSkillRepository.java @@ -1,9 +1,17 @@ package synk.meeteam.domain.user.user_skill.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import synk.meeteam.domain.user.user_skill.entity.UserSkill; public interface UserSkillRepository extends JpaRepository, UserSkillCustomRepository { void deleteAllByCreatedBy(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("DELETE FROM UserSkill u WHERE u.createdBy = :userId") + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/synk/meeteam/global/aop/ExecutionLoggingAop.java b/src/main/java/synk/meeteam/global/aop/ExecutionLoggingAop.java new file mode 100644 index 00000000..5aeaa552 --- /dev/null +++ b/src/main/java/synk/meeteam/global/aop/ExecutionLoggingAop.java @@ -0,0 +1,78 @@ +package synk.meeteam.global.aop; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import lombok.extern.log4j.Log4j2; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import synk.meeteam.security.CustomAuthUser; + +@Aspect +@Component +@Log4j2 +public class ExecutionLoggingAop { + + // 모든 패키지 내의 controller package에 존재하는 클래스 + @Around("execution(* synk.meeteam.domain..api..*(..))") + public Object logExecutionTrace(ProceedingJoinPoint pjp) throws Throwable { + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + RequestMethod httpMethod = RequestMethod.valueOf(request.getMethod()); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Long userId = 0L; + if (!authentication.getPrincipal().equals("anonymousUser")) { + CustomAuthUser customAuthUser = (CustomAuthUser) authentication.getPrincipal(); + userId = customAuthUser.getUser().getId(); + } + + String className = pjp.getSignature().getDeclaringType().getSimpleName(); + String methodName = pjp.getSignature().getName(); + String task = className + "." + methodName; + + log.info("[Call Method] " + httpMethod.toString() + ": " + task + " | Request userId=" + userId.toString()); + + Object[] paramArgs = pjp.getArgs(); + String loggingMessage = ""; + int cnt = 1; + for (Object object : paramArgs) { + if (Objects.nonNull(object)) { + String paramName = "[param" + cnt +"] " + object.getClass().getSimpleName(); + String paramValue = " [value" + cnt +"] " + object; + loggingMessage += paramName + paramValue + "\n"; + cnt++; + } + } + log.info("{}", loggingMessage); + // 해당 클래스 처리 전의 시간 + StopWatch sw = new StopWatch(); + sw.start(); + + Object result = null; + + // 해당 클래스의 메소드 실행 + try{ + result = pjp.proceed(); + } + catch (Exception e){ + log.warn("[ERROR] " + task + " 메서드 예외 발생 : " + e.getMessage()); + throw e; + } + + // 해당 클래스 처리 후의 시간 + sw.stop(); + long executionTime = sw.getTotalTimeMillis(); + + log.info("[ExecutionTime] " + task + " --> " + executionTime + " (ms)"); + + return result; + } + +} diff --git a/src/main/java/synk/meeteam/global/api/ServerProfileController.java b/src/main/java/synk/meeteam/global/api/ServerProfileController.java index a2fd6733..d8d4d9da 100644 --- a/src/main/java/synk/meeteam/global/api/ServerProfileController.java +++ b/src/main/java/synk/meeteam/global/api/ServerProfileController.java @@ -1,12 +1,7 @@ package synk.meeteam.global.api; -import static synk.meeteam.infra.s3.S3FileName.PORTFOLIO; -import static synk.meeteam.infra.s3.S3FileName.USER; - -import io.swagger.v3.oas.annotations.Operation; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.core.env.Environment; import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; @@ -15,63 +10,42 @@ import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.portfolio.portfolio.service.PortfolioService; import synk.meeteam.domain.user.user.entity.User; -import synk.meeteam.global.entity.ServiceType; -import synk.meeteam.global.util.Encryption; -import synk.meeteam.infra.s3.service.S3Service; -import synk.meeteam.infra.s3.service.vo.PreSignedUrlVO; +import synk.meeteam.infra.aws.service.CloudFrontService; +import synk.meeteam.infra.aws.service.vo.SignedUrlVO; import synk.meeteam.security.AuthUser; @RestController @RequiredArgsConstructor public class ServerProfileController { - private static final String ZIP_NAME = "zip"; - - private final Environment env; - private final S3Service s3Service; private final PortfolioService portfolioService; + private final CloudFrontService cloudFrontService; - @Operation(summary = "프론트에서 필요없는 API 입니다.") - @GetMapping("/profile") - public ResponseEntity getProfile() { - return ResponseEntity.ok(env.getActiveProfiles()[0]); - } - - // THUMBNAIL_PORTFOLIO - - @GetMapping("/profile/pre-signed-url") - public ResponseEntity getPreSignedUrl(@AuthUser User user, - @RequestParam(name = "file-name") String fileName) { + @GetMapping("/profile/signed-url") + public ResponseEntity getSignedUrl(@AuthUser User user, + @RequestParam(name = "file-name") String fileName) { String extension = StringUtils.getFilenameExtension(fileName); - return ResponseEntity.ok().body(s3Service.getUploadPreSignedUrl(USER, Encryption.encryptLong(user.getId()), - extension, ServiceType.PROFILE)); + return ResponseEntity.ok().body(cloudFrontService.getProfileSignedUrl(extension, user.getId())); } - @GetMapping("/portfolio/pre-signed-url") - public ResponseEntity> getPreSignedUrl(@AuthUser User user, - @RequestParam(name = "file-name") String fileName, - @RequestParam(name = "portfolio", required = false) Long portfolioId + @GetMapping("/portfolio/signed-url") + public ResponseEntity> getSignedUrl(@AuthUser User user, + @RequestParam(name = "file-name") String fileName, + @RequestParam(name = "portfolio", required = false) Long portfolioId ) { - - String extension = StringUtils.getFilenameExtension(fileName); - - String zipFileName = null; - String thumbNailFileName = null; + String thumbnailExtension = StringUtils.getFilenameExtension(fileName); + Portfolio portfolio = null; if (portfolioId != null) { - // 수정의 경우 - Portfolio portfolio = portfolioService.getPortfolio(portfolioId, user); - zipFileName = portfolio.getZipFileName(); - thumbNailFileName = portfolio.getMainImageFileName(); + portfolio = portfolioService.getPortfolio(portfolioId); } - PreSignedUrlVO zipUrl = s3Service.getUploadPreSignedUrl(PORTFOLIO, zipFileName, - ZIP_NAME, ServiceType.PORTFOLIOS); - PreSignedUrlVO thumbNailUrl = s3Service.getUploadPreSignedUrl(PORTFOLIO, thumbNailFileName, - extension, ServiceType.THUMBNAIL_PORTFOLIO); - - return ResponseEntity.ok().body(List.of(zipUrl, thumbNailUrl)); + return ResponseEntity.ok().body( + cloudFrontService.getPortfolioSignedUrl(thumbnailExtension, portfolio, user.getId()) + ); } + + } diff --git a/src/main/java/synk/meeteam/global/common/exception/GlobalExceptionHandler.java b/src/main/java/synk/meeteam/global/common/exception/GlobalExceptionHandler.java index 05fbe49b..9fddbe6f 100644 --- a/src/main/java/synk/meeteam/global/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/synk/meeteam/global/common/exception/GlobalExceptionHandler.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @RestControllerAdvice @@ -92,6 +93,15 @@ public ResponseEntity handleHttpRequestMethodNotSupportedExce .body(ExceptionResponse.of(exceptionType.name(), exceptionType.message())); } + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException( + NoResourceFoundException e) { + ExceptionType exceptionType = SS_100; + //log.warn(String.format(LOG_FORMAT, e.getMessage()), e); + return ResponseEntity.status(exceptionType.httpStatus()) + .body(ExceptionResponse.of(exceptionType.name(), exceptionType.message())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error(String.format(LOG_FORMAT, e.getMessage()), e); diff --git a/src/main/java/synk/meeteam/global/config/AsyncConfig.java b/src/main/java/synk/meeteam/global/config/AsyncConfig.java new file mode 100644 index 00000000..9cb11b02 --- /dev/null +++ b/src/main/java/synk/meeteam/global/config/AsyncConfig.java @@ -0,0 +1,23 @@ +package synk.meeteam.global.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurerSupport; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig extends AsyncConfigurerSupport { + + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); // 기본적으로 실행 대기 중인 Thread 개수 + executor.setMaxPoolSize(10); // 동시에 동작하는 최대 Thread 개수 + executor.setQueueCapacity(500); // CorePool이 초과될때 Queue에 저장했다가 꺼내서 실행된다. (500개까지 저장함) + + executor.setThreadNamePrefix("async-"); // Spring에서 생성하는 Thread 이름의 접두사 + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/synk/meeteam/global/config/SwaggerConfig.java b/src/main/java/synk/meeteam/global/config/SwaggerConfig.java index 6b58fc97..4f617af5 100644 --- a/src/main/java/synk/meeteam/global/config/SwaggerConfig.java +++ b/src/main/java/synk/meeteam/global/config/SwaggerConfig.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.tags.Tag; import java.util.Arrays; import java.util.List; @@ -46,19 +47,15 @@ public SwaggerConfig(@Value("${domain.server-domain}") String serverDomain) { @Bean public OpenAPI openAPI() { - io.swagger.v3.oas.models.servers.Server devServer = new io.swagger.v3.oas.models.servers.Server(); + Server devServer = new Server(); devServer.setDescription("dev server"); devServer.setUrl(SERVER_DOMAIN); - io.swagger.v3.oas.models.servers.Server localServer = new io.swagger.v3.oas.models.servers.Server(); - localServer.setDescription("local server"); - localServer.setUrl("http://localhost:5173"); - OpenAPI info = new OpenAPI() .components(new Components()) .info(apiInfo()); - info.setServers(Arrays.asList(devServer, localServer)); + info.setServers(Arrays.asList(devServer)); return info; } diff --git a/src/main/java/synk/meeteam/global/config/WebMvcConfig.java b/src/main/java/synk/meeteam/global/config/WebMvcConfig.java new file mode 100644 index 00000000..f73ff6eb --- /dev/null +++ b/src/main/java/synk/meeteam/global/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package synk.meeteam.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import synk.meeteam.global.util.HtmlCharacterEscapes; + +@Slf4j +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig { + + private final ObjectMapper objectMapper; + + @Bean + public MappingJackson2HttpMessageConverter jsonEscapeConverter() { + ObjectMapper copy = objectMapper.copy(); + copy.getFactory().setCharacterEscapes(new HtmlCharacterEscapes()); + return new MappingJackson2HttpMessageConverter(copy); + } +} diff --git a/src/main/java/synk/meeteam/global/dto/PaginationDto.java b/src/main/java/synk/meeteam/global/dto/PaginationDto.java new file mode 100644 index 00000000..9d4ba37f --- /dev/null +++ b/src/main/java/synk/meeteam/global/dto/PaginationDto.java @@ -0,0 +1,9 @@ +package synk.meeteam.global.dto; + +import java.util.List; + +public record PaginationDto( + List data, + PageInfo pageInfo +) { +} diff --git a/src/main/java/synk/meeteam/global/dto/PaginationPortfolioDto.java b/src/main/java/synk/meeteam/global/dto/PaginationPortfolioDto.java new file mode 100644 index 00000000..c2c6a522 --- /dev/null +++ b/src/main/java/synk/meeteam/global/dto/PaginationPortfolioDto.java @@ -0,0 +1,9 @@ +package synk.meeteam.global.dto; + +import java.util.List; + +public record PaginationPortfolioDto( + List portfolios, + PageInfo pageInfo +) { +} diff --git a/src/main/java/synk/meeteam/global/entity/DeleteStatus.java b/src/main/java/synk/meeteam/global/entity/DeleteStatus.java new file mode 100644 index 00000000..a3e8fcca --- /dev/null +++ b/src/main/java/synk/meeteam/global/entity/DeleteStatus.java @@ -0,0 +1,5 @@ +package synk.meeteam.global.entity; + +public enum DeleteStatus { + ALIVE, DELETED +} diff --git a/src/main/java/synk/meeteam/global/entity/ProceedType.java b/src/main/java/synk/meeteam/global/entity/ProceedType.java index 5e9fcfed..baf486bc 100644 --- a/src/main/java/synk/meeteam/global/entity/ProceedType.java +++ b/src/main/java/synk/meeteam/global/entity/ProceedType.java @@ -12,7 +12,7 @@ public enum ProceedType { ON_LINE("온라인"), OFF_LINE("오프라인"), - NO_MATTER("상관없음"); + NO_MATTER("온/오프라인"); private final String name; diff --git a/src/main/java/synk/meeteam/global/util/HtmlCharacterEscapes.java b/src/main/java/synk/meeteam/global/util/HtmlCharacterEscapes.java new file mode 100644 index 00000000..aecfbf7e --- /dev/null +++ b/src/main/java/synk/meeteam/global/util/HtmlCharacterEscapes.java @@ -0,0 +1,43 @@ +package synk.meeteam.global.util; + +import com.fasterxml.jackson.core.SerializableString; +import com.fasterxml.jackson.core.io.CharacterEscapes; +import com.fasterxml.jackson.core.io.SerializedString; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringEscapeUtils; + +@Slf4j +public class HtmlCharacterEscapes extends CharacterEscapes { + + private final int[] asciiEscapes; + + public HtmlCharacterEscapes() { + // 1. XSS 방지 처리할 특수 문자 지정 + asciiEscapes = CharacterEscapes.standardAsciiEscapesForJSON(); + asciiEscapes['<'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['>'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['\"'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['('] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes[')'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['#'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['\''] = CharacterEscapes.ESCAPE_CUSTOM; + } + + @Override + public int[] getEscapeCodesForAscii() { + return asciiEscapes; + } + + @Override + public SerializableString getEscapeSequence(int ch) { + char charAt = (char) ch; + if (Character.isHighSurrogate(charAt) || Character.isLowSurrogate(charAt)) { + StringBuilder sb = new StringBuilder(); + sb.append("\\u"); + sb.append(String.format("%04x", ch)); + return new SerializedString(sb.toString()); + } else { + return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString(charAt))); + } + } +} diff --git a/src/main/java/synk/meeteam/global/util/UnescapedFieldSerializer.java b/src/main/java/synk/meeteam/global/util/UnescapedFieldSerializer.java new file mode 100644 index 00000000..79c5ea54 --- /dev/null +++ b/src/main/java/synk/meeteam/global/util/UnescapedFieldSerializer.java @@ -0,0 +1,22 @@ +package synk.meeteam.global.util; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.io.CharacterEscapes; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; + +public class UnescapedFieldSerializer extends JsonSerializer { + + + @Override + public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // 구인글 "content"에 한해서 escape 하지 않고 그대로 출력 + // 잠시 이스케이핑 설정을 on/off 하는 방식 + CharacterEscapes characterEscapes = gen.getCharacterEscapes(); + gen.setCharacterEscapes(null); + gen.writeString(value); + gen.setCharacterEscapes(characterEscapes); + } +} + diff --git a/src/main/java/synk/meeteam/infra/aws/S3FilePath.java b/src/main/java/synk/meeteam/infra/aws/S3FilePath.java new file mode 100644 index 00000000..bd064824 --- /dev/null +++ b/src/main/java/synk/meeteam/infra/aws/S3FilePath.java @@ -0,0 +1,19 @@ +package synk.meeteam.infra.aws; + +public class S3FilePath { + public static final String USER = "user/"; + public static final String PORTFOLIO = "portfolio/"; + + public static String getPortfolioPath(String encryptedUserId) { + return PORTFOLIO + encryptedUserId + "/"; + } + + public static String getZipFileFullName(String filename) { + return filename + ".zip"; + } + + public static String getFileFullName(String filename, String extension) { + return filename + "." + extension; + } + +} diff --git a/src/main/java/synk/meeteam/infra/s3/config/S3Config.java b/src/main/java/synk/meeteam/infra/aws/config/AwsConfig.java similarity index 84% rename from src/main/java/synk/meeteam/infra/s3/config/S3Config.java rename to src/main/java/synk/meeteam/infra/aws/config/AwsConfig.java index a359fe30..0c4c98b3 100644 --- a/src/main/java/synk/meeteam/infra/s3/config/S3Config.java +++ b/src/main/java/synk/meeteam/infra/aws/config/AwsConfig.java @@ -1,4 +1,4 @@ -package synk.meeteam.infra.s3.config; +package synk.meeteam.infra.aws.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -9,7 +9,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration -public class S3Config { +public class AwsConfig { private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; private static final String AWS_REGION = "aws.region"; @@ -18,9 +18,9 @@ public class S3Config { private final String secretKey; private final String regionString; - public S3Config(@Value("${aws-property.access-key}") final String accessKey, - @Value("${aws-property.secret-key}") final String secretKey, - @Value("${aws-property.aws-region}") final String regionString) { + public AwsConfig(@Value("${aws-property.access-key}") final String accessKey, + @Value("${aws-property.secret-key}") final String secretKey, + @Value("${aws-property.aws-region}") final String regionString) { this.accessKey = accessKey; this.secretKey = secretKey; this.regionString = regionString; diff --git a/src/main/java/synk/meeteam/infra/aws/exception/AwsException.java b/src/main/java/synk/meeteam/infra/aws/exception/AwsException.java new file mode 100644 index 00000000..4680d741 --- /dev/null +++ b/src/main/java/synk/meeteam/infra/aws/exception/AwsException.java @@ -0,0 +1,10 @@ +package synk.meeteam.infra.aws.exception; + +import synk.meeteam.global.common.exception.BaseCustomException; +import synk.meeteam.global.common.exception.ExceptionType; + +public class AwsException extends BaseCustomException { + public AwsException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/src/main/java/synk/meeteam/infra/aws/exception/AwsExceptionType.java b/src/main/java/synk/meeteam/infra/aws/exception/AwsExceptionType.java new file mode 100644 index 00000000..0e60a9db --- /dev/null +++ b/src/main/java/synk/meeteam/infra/aws/exception/AwsExceptionType.java @@ -0,0 +1,24 @@ +package synk.meeteam.infra.aws.exception; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import synk.meeteam.global.common.exception.ExceptionType; + +@RequiredArgsConstructor +public enum AwsExceptionType implements ExceptionType { + FAIL_GET_URL(HttpStatus.BAD_REQUEST, "URL 요청에 실패하였습니다."), + CAN_NOT_FOUND_KEY(HttpStatus.NOT_FOUND, "키를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/synk/meeteam/infra/aws/service/CloudFrontService.java b/src/main/java/synk/meeteam/infra/aws/service/CloudFrontService.java new file mode 100644 index 00000000..07cc0010 --- /dev/null +++ b/src/main/java/synk/meeteam/infra/aws/service/CloudFrontService.java @@ -0,0 +1,97 @@ +package synk.meeteam.infra.aws.service; + +import static synk.meeteam.infra.aws.S3FilePath.USER; +import static synk.meeteam.infra.aws.S3FilePath.getFileFullName; +import static synk.meeteam.infra.aws.S3FilePath.getPortfolioPath; +import static synk.meeteam.infra.aws.S3FilePath.getZipFileFullName; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.services.cloudfront.CloudFrontUtilities; +import software.amazon.awssdk.services.cloudfront.model.CannedSignerRequest; +import software.amazon.awssdk.services.cloudfront.url.SignedUrl; +import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; +import synk.meeteam.global.entity.ServiceType; +import synk.meeteam.global.util.Encryption; +import synk.meeteam.infra.aws.exception.AwsException; +import synk.meeteam.infra.aws.exception.AwsExceptionType; +import synk.meeteam.infra.aws.service.vo.SignedUrlVO; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CloudFrontService { + private static final Long PRE_SIGNED_URL_EXPIRE_SECONDS = 30L; + @Value("${aws-property.distribution-domain}") + private String distributionDomain; + @Value("${aws-property.private-key-file-path}") + private String privateKeyFilePath; + @Value("${aws-property.key-pair-id}") + private String keyPairId; + + public String getSignedUrl(String path, String fileName) { + try { + String resourcePath = getEncodedResourcePath(path, fileName); + String cloudFrontUrl = "https://" + distributionDomain + "/" + resourcePath; + Instant expirationTime = Instant.now().plus(PRE_SIGNED_URL_EXPIRE_SECONDS, ChronoUnit.SECONDS); + Path keyPath = Paths.get(privateKeyFilePath); + CloudFrontUtilities cloudFrontUtilities = CloudFrontUtilities.create(); + CannedSignerRequest cannedSignerRequest = CannedSignerRequest.builder() + .resourceUrl(cloudFrontUrl) + .privateKey(keyPath) + .keyPairId(keyPairId) + .expirationDate(expirationTime) + .build(); + + SignedUrl signedUrl = cloudFrontUtilities.getSignedUrlWithCannedPolicy(cannedSignerRequest); + return signedUrl.url(); + } catch (AwsException e) { + throw new AwsException(AwsExceptionType.FAIL_GET_URL); + } catch (Exception e) { + throw new AwsException(AwsExceptionType.CAN_NOT_FOUND_KEY); + } + } + + public SignedUrlVO getProfileSignedUrl(String extension, Long userId) { + String filename = getFileFullName(Encryption.encryptLong(userId), extension); + String url = getSignedUrl(USER, filename); + return SignedUrlVO.of(ServiceType.PROFILE, filename, url); + } + + public List getPortfolioSignedUrl(String thumbnailExtension, Portfolio portfolio, Long userId) { + + String zipFileName = getZipFileFullName(UUID.randomUUID().toString()); + String thumbNailFileName = UUID.randomUUID().toString(); + if (portfolio != null) { + zipFileName = portfolio.getZipFileName(); + thumbNailFileName = StringUtils.stripFilenameExtension(portfolio.getMainImageFileName()); + } + thumbNailFileName = getFileFullName(thumbNailFileName, thumbnailExtension); + + String encryptedId = Encryption.encryptLong(userId); + String zipUrl = getSignedUrl(getPortfolioPath(encryptedId), zipFileName); + String thumbNailUrl = getSignedUrl(getPortfolioPath(encryptedId), thumbNailFileName); + + return List.of( + SignedUrlVO.of(ServiceType.PORTFOLIOS, zipFileName, zipUrl), + SignedUrlVO.of(ServiceType.THUMBNAIL_PORTFOLIO, thumbNailFileName, thumbNailUrl) + ); + } + + private String getEncodedResourcePath(String path, String fileName) throws UnsupportedEncodingException { + String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); + return path + encodedFileName; + } +} \ No newline at end of file diff --git a/src/main/java/synk/meeteam/infra/s3/service/vo/PreSignedUrlVO.java b/src/main/java/synk/meeteam/infra/aws/service/vo/SignedUrlVO.java similarity index 50% rename from src/main/java/synk/meeteam/infra/s3/service/vo/PreSignedUrlVO.java rename to src/main/java/synk/meeteam/infra/aws/service/vo/SignedUrlVO.java index efcf6012..aa37a20d 100644 --- a/src/main/java/synk/meeteam/infra/s3/service/vo/PreSignedUrlVO.java +++ b/src/main/java/synk/meeteam/infra/aws/service/vo/SignedUrlVO.java @@ -1,4 +1,4 @@ -package synk.meeteam.infra.s3.service.vo; +package synk.meeteam.infra.aws.service.vo; import lombok.AllArgsConstructor; @@ -7,13 +7,13 @@ @Data @AllArgsConstructor -public class PreSignedUrlVO { +public class SignedUrlVO { private ServiceType serviceType; private String fileName; private String url; - public static PreSignedUrlVO of(ServiceType serviceType, String fileName, String url) { - return new PreSignedUrlVO(serviceType, fileName, url); + public static SignedUrlVO of(ServiceType serviceType, String fileName, String url) { + return new SignedUrlVO(serviceType, fileName, url); } } diff --git a/src/main/java/synk/meeteam/infra/mail/MailService.java b/src/main/java/synk/meeteam/infra/mail/MailService.java index 98faaec3..5447cbcc 100644 --- a/src/main/java/synk/meeteam/infra/mail/MailService.java +++ b/src/main/java/synk/meeteam/infra/mail/MailService.java @@ -2,11 +2,14 @@ import static synk.meeteam.domain.auth.exception.AuthExceptionType.INVALID_MAIL_SERVICE; import static synk.meeteam.infra.mail.MailText.CHAR_SET; -import static synk.meeteam.infra.mail.MailText.FRONT_DOMAIN; -import static synk.meeteam.infra.mail.MailText.MAIL_CONTENT_POSTFIX_SIGNUP; -import static synk.meeteam.infra.mail.MailText.MAIL_CONTENT_PREFIX_SIGNUP; +import static synk.meeteam.infra.mail.MailText.FRONT_VERIFY_DOMAIN; +import static synk.meeteam.infra.mail.MailText.LOGO_URL; +import static synk.meeteam.infra.mail.MailText.MAIL_APPLICATION_NOTIFICATION_TEMPLATE; +import static synk.meeteam.infra.mail.MailText.MAIL_APPROVE_TEMPLATE; +import static synk.meeteam.infra.mail.MailText.MAIL_TITLE_APPLICATION; import static synk.meeteam.infra.mail.MailText.MAIL_TITLE_APPROVE; import static synk.meeteam.infra.mail.MailText.MAIL_TITLE_SIGNUP; +import static synk.meeteam.infra.mail.MailText.MAIL_VERIFY_TEMPLATE; import static synk.meeteam.infra.mail.MailText.SENDER; import static synk.meeteam.infra.mail.MailText.SENDER_ADDRESS; import static synk.meeteam.infra.mail.MailText.SUB_TYPE; @@ -14,11 +17,11 @@ import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage.RecipientType; -import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.thymeleaf.TemplateEngine; @@ -39,12 +42,10 @@ public class MailService { private final TemplateEngine templateEngine; - private static String createMailContent(String emailCode) { - return MAIL_CONTENT_PREFIX_SIGNUP + FRONT_DOMAIN + emailCode + MAIL_CONTENT_POSTFIX_SIGNUP; - } + public String generateApproveMailForm(String userName, String postId, String postName, String roleName, + String writerName, String link) { - public String generateHtml(String templatePath, String userName, String postId, String postName, String roleName, - String writerName) { + String logoUrl = LOGO_URL; // Thymeleaf context 생성 Context context = new Context(); @@ -53,13 +54,49 @@ public String generateHtml(String templatePath, String userName, String postId, context.setVariable("postId", postId); context.setVariable("roleName", roleName); context.setVariable("writerName", writerName); + context.setVariable("logoUrl", logoUrl); + context.setVariable("link", link); + + // Thymeleaf 템플릿 엔진을 사용하여 변수 값을 주입하여 HTML 렌더링 + return templateEngine.process(MAIL_APPROVE_TEMPLATE, context); + } + + public String generateEmailVerifyMailForm(String userName, String emailCode) { + + String verifyUrl = FRONT_VERIFY_DOMAIN + emailCode.toString(); + String logoUrl = LOGO_URL; + + // Thymeleaf context 생성 + Context context = new Context(); + context.setVariable("userName", userName); + context.setVariable("verifyUrl", verifyUrl); + context.setVariable("logoUrl", logoUrl); // Thymeleaf 템플릿 엔진을 사용하여 변수 값을 주입하여 HTML 렌더링 - return templateEngine.process(templatePath, context); + return templateEngine.process(MAIL_VERIFY_TEMPLATE, context); } + public String generateApplicationNotificationMail(String writerName, String userName, String postId, + String postName, String roleName) { + + String logoUrl = LOGO_URL; + + // Thymeleaf context 생성 + Context context = new Context(); + context.setVariable("writerName", writerName); + context.setVariable("userName", userName); + context.setVariable("postName", postName); + context.setVariable("postId", postId); + context.setVariable("roleName", roleName); + context.setVariable("logoUrl", logoUrl); + + // Thymeleaf 템플릿 엔진을 사용하여 변수 값을 주입하여 HTML 렌더링 + return templateEngine.process(MAIL_APPLICATION_NOTIFICATION_TEMPLATE, context); + } + + @Async @Transactional - public void sendMail(String platformId, String receiverMail) { + public void sendVerifyMail(String platformId, String receiverMail) { String newEmailCode = UUID.randomUUID().toString(); UserVO userVO = redisUserRepository.findByPlatformIdOrElseThrowException(platformId); @@ -69,14 +106,14 @@ public void sendMail(String platformId, String receiverMail) { MimeMessage message = mailSender.createMimeMessage(); try { - message.addRecipients(RecipientType.TO, receiverMail);// 보내는 대상 + message.addRecipients(RecipientType.TO, receiverMail);// 받는 대상 message.setSubject(MAIL_TITLE_SIGNUP);// 제목 - String body = createMailContent(newEmailCode); + String body = generateEmailVerifyMailForm(userVO.getName(), newEmailCode); message.setText(body, CHAR_SET, SUB_TYPE);// 내용, charset 타입, subtype // 보내는 사람의 이메일 주소, 보내는 사람 이름 - message.setFrom(new InternetAddress(SENDER_ADDRESS, SENDER));// 보내는 사람 + message.setFrom(new InternetAddress(SENDER_ADDRESS, SENDER)); // 보내는 대상 mailSender.send(message); // 메일 전송 } catch (Exception e) { log.info("{}", e); @@ -84,35 +121,57 @@ public void sendMail(String platformId, String receiverMail) { } } + + @Async @Transactional - public void sendApproveMails(Long postId, List applicants, String writerName) { - if (applicants == null) { - return; + public void sendApproveMail(Long postId, RecruitmentApplicant recruitmentApplicant, String writerName) { + + MimeMessage message = mailSender.createMimeMessage(); + + User applicant = recruitmentApplicant.getApplicant(); + + try { + message.addRecipients(RecipientType.TO, applicant.getMainEmail());// 받는 대상 + message.setSubject(MAIL_TITLE_APPROVE);// 제목 + + String body = generateApproveMailForm(applicant.getName(), postId.toString(), + recruitmentApplicant.getRecruitmentPost().getTitle(), + recruitmentApplicant.getRole().getName(), writerName, + recruitmentApplicant.getRecruitmentPost().getKakaoLink()); + + message.setText(body, CHAR_SET, SUB_TYPE);// 내용, charset 타입, subtype + // 보내는 사람의 이메일 주소, 보내는 사람 이름 + message.setFrom(new InternetAddress(SENDER_ADDRESS, SENDER));// 보내는 대상 + mailSender.send(message); // 메일 전송 + } catch (Exception e) { + log.info("{}", e); + throw new AuthException(INVALID_MAIL_SERVICE); } + + } + + @Async + @Transactional + public void sendApplicationNotificationMail(Long postId, RecruitmentApplicant recruitmentApplicant, User writer) { + MimeMessage message = mailSender.createMimeMessage(); + User applicant = recruitmentApplicant.getApplicant(); + + try { + message.addRecipients(RecipientType.TO, writer.getMainEmail()); // 받는 대상 + message.setSubject(MAIL_TITLE_APPLICATION);// 제목 + + String body = generateApplicationNotificationMail(writer.getName(), applicant.getName(), postId.toString(), + recruitmentApplicant.getRecruitmentPost().getTitle(), + recruitmentApplicant.getRole().getName()); - for (RecruitmentApplicant recruitmentApplicant : applicants) { - User applicant = recruitmentApplicant.getApplicant(); - String receiverMail = - applicant.isUniversityMainEmail() ? applicant.getUniversityEmail() : applicant.getSubEmail(); - - try { - message.addRecipients(RecipientType.TO, receiverMail);// 보내는 대상 - message.setSubject(MAIL_TITLE_APPROVE);// 제목 - - String body = generateHtml( - "ApproveMail", applicant.getName(), postId.toString(), - recruitmentApplicant.getRecruitmentPost().getTitle(), - recruitmentApplicant.getRole().getName(), writerName); - - message.setText(body, CHAR_SET, SUB_TYPE);// 내용, charset 타입, subtype - // 보내는 사람의 이메일 주소, 보내는 사람 이름 - message.setFrom(new InternetAddress(SENDER_ADDRESS, SENDER));// 보내는 사람 - mailSender.send(message); // 메일 전송 - } catch (Exception e) { - log.info("{}", e); - throw new AuthException(INVALID_MAIL_SERVICE); - } + message.setText(body, CHAR_SET, SUB_TYPE);// 내용, charset 타입, subtype + // 보내는 사람의 이메일 주소, 보내는 사람 이름 + message.setFrom(new InternetAddress(SENDER_ADDRESS, SENDER));// 보내는 대상 + mailSender.send(message); // 메일 전송 + } catch (Exception e) { + log.info("{}", e); + throw new AuthException(INVALID_MAIL_SERVICE); } } @@ -121,4 +180,10 @@ public void sendApproveMails(Long postId, List applicants, public UserVO verify(String emailCode) { return redisUserRepository.findByEmailCodeOrElseThrowException(emailCode); } + + @Transactional + public void deleteTemporaryUser(String emailCode) { + UserVO userVO = redisUserRepository.findByEmailCodeOrElseThrowException(emailCode); + redisUserRepository.delete(userVO); + } } diff --git a/src/main/java/synk/meeteam/infra/mail/MailText.java b/src/main/java/synk/meeteam/infra/mail/MailText.java index 7100d13f..e9792dae 100644 --- a/src/main/java/synk/meeteam/infra/mail/MailText.java +++ b/src/main/java/synk/meeteam/infra/mail/MailText.java @@ -1,31 +1,27 @@ package synk.meeteam.infra.mail; public class MailText { - public static final String MAIL_TITLE_SIGNUP = "Meeteam 회원가입 이메일 인증"; - public static final String MAIL_TITLE_APPROVE = "구인 신청 승인"; - public static final String MAIL_CONTENT_PREFIX_SIGNUP = "
" - + "

안녕하세요. Meeteam 입니다

" - + "
" - + "

아래 링크를 클릭하면 이메일 인증이 완료됩니다.

" - + ""; - - public static final String MAIL_CONTENT_POSTFIX_APPROVE = "'>구인글 링크" - + "

"; - - public static final String SENDER_ADDRESS = "thdalsrb79@naver.com"; - public static final String SENDER = "Meeteam"; + // 공통 설정 + public static final String SENDER_ADDRESS = "meeteam@naver.com"; + public static final String SENDER = "MeeTeam"; public static final String CHAR_SET = "utf-8"; public static final String SUB_TYPE = "html"; + public static final String LOGO_URL = "https://firebasestorage.googleapis.com/v0/b/meeteam-operation.appspot.com/o/meeteam-logo.svg?alt=media&token=37065a86-e757-410b-83c6-cbde150a5181"; + + + // 이메일 인증 관련 + public static final String MAIL_TITLE_SIGNUP = "Meeteam 회원가입 이메일 인증"; + public static final String MAIL_VERIFY_TEMPLATE = "UniversityAuthMail"; + public static final String FRONT_VERIFY_DOMAIN = "https://www.meeteam.co.kr/signup/nickname?emailcode="; + + + // 구인글 승인 관련 + public static final String MAIL_TITLE_APPROVE = "구인 신청 승인"; + public static final String MAIL_APPROVE_TEMPLATE = "ApproveMail"; + // 구인글 신청 알림 관련 + public static final String MAIL_TITLE_APPLICATION = "구인 신청 알림"; + public static final String MAIL_APPLICATION_NOTIFICATION_TEMPLATE = "ApplicationNotificationMail"; } diff --git a/src/main/java/synk/meeteam/infra/oauth/service/NaverAuthService.java b/src/main/java/synk/meeteam/infra/oauth/service/NaverAuthService.java index f650b703..da828824 100644 --- a/src/main/java/synk/meeteam/infra/oauth/service/NaverAuthService.java +++ b/src/main/java/synk/meeteam/infra/oauth/service/NaverAuthService.java @@ -9,7 +9,7 @@ import synk.meeteam.domain.auth.exception.AuthException; import synk.meeteam.domain.auth.exception.AuthExceptionType; import synk.meeteam.domain.auth.service.AuthService; -import synk.meeteam.domain.auth.service.vo.AuthUserVo; +import synk.meeteam.domain.auth.service.vo.AuthUserVO; import synk.meeteam.domain.common.department.repository.DepartmentRepository; import synk.meeteam.domain.common.university.repository.UniversityRepository; import synk.meeteam.domain.user.user.entity.User; @@ -42,20 +42,20 @@ public NaverAuthService(UserRepository userRepository, RedisUserRepository redis } @Override - public AuthUserVo saveUserOrLogin(String authorizationCode, AuthUserRequestDto request) { + public AuthUserVO saveUserOrLogin(String authorizationCode, AuthUserRequestDto request) { String accessToken = getAccessToken(authorizationCode, clientId, clientSecret, state).getAccess_token(); NaverMemberVO naverMemberInfo = getNaverUserInfo(accessToken); - User foundUser = getUser(request.platformType(), naverMemberInfo.getResponse().getId()); + User foundUser = getUser(naverMemberInfo.getResponse().getId(), request.platformType()); if (foundUser != null) { - return AuthUserVo.of(foundUser, request.platformType(), Authority.USER, AuthType.LOGIN); + return AuthUserVO.of(foundUser, request.platformType(), Authority.USER, AuthType.LOGIN); } User savedUser = saveTempUser(request, naverMemberInfo.getResponse().getEmail(), naverMemberInfo.getResponse().getName(), naverMemberInfo.getResponse().getId(), naverMemberInfo.getResponse().getMobile(), naverMemberInfo.getResponse().getProfile_image()); - return AuthUserVo.of(savedUser, request.platformType(), Authority.GUEST, AuthType.SIGN_UP); + return AuthUserVO.of(savedUser, request.platformType(), Authority.GUEST, AuthType.SIGN_UP); } diff --git a/src/main/java/synk/meeteam/infra/rds/SshDataSourceConfig.java b/src/main/java/synk/meeteam/infra/rds/SshDataSourceConfig.java new file mode 100644 index 00000000..711d2943 --- /dev/null +++ b/src/main/java/synk/meeteam/infra/rds/SshDataSourceConfig.java @@ -0,0 +1,36 @@ +package synk.meeteam.infra.rds; + +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +@Slf4j +@Profile("stg") +@Configuration +@RequiredArgsConstructor +public class SshDataSourceConfig { + + private final SshTunnelingInitializer initializer; + + @Bean("dataSource") + @Primary + public DataSource dataSource(DataSourceProperties properties) { + + Integer forwardedPort = initializer.buildSshConnection(); // ssh 연결 및 터널링 설정 + String url = properties.getUrl().replace("[forwardedPort]", Integer.toString(forwardedPort)); + log.info(url); + return DataSourceBuilder.create() + .url(url) + .username(properties.getUsername()) + .password(properties.getPassword()) + .driverClassName(properties.getDriverClassName()) + .build(); + } + +} diff --git a/src/main/java/synk/meeteam/infra/rds/SshTunnelingInitializer.java b/src/main/java/synk/meeteam/infra/rds/SshTunnelingInitializer.java new file mode 100644 index 00000000..05cc94b9 --- /dev/null +++ b/src/main/java/synk/meeteam/infra/rds/SshTunnelingInitializer.java @@ -0,0 +1,82 @@ +package synk.meeteam.infra.rds; + +import static java.lang.System.exit; + +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import jakarta.annotation.PreDestroy; +import jakarta.validation.constraints.NotNull; +import java.util.Properties; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Slf4j +@Profile("stg") +@Component +@ConfigurationProperties(prefix = "ssh") +@Validated +@Setter +public class SshTunnelingInitializer { + + @NotNull + private String remoteJumpHost; + @NotNull + private String user; + @NotNull + private int sshPort; + @NotNull + private String privateKey; + @NotNull + private String databaseUrl; + @NotNull + private int databasePort; + + private Session session; + + @PreDestroy + public void closeSSH() { + if (session.isConnected()) + session.disconnect(); + } + + public Integer buildSshConnection() { + + Integer forwardedPort = null; + + try { + log.info("{}@{}:{}:{} with privateKey",user, remoteJumpHost, sshPort, databasePort); + + log.info("start ssh tunneling.."); + JSch jSch = new JSch(); + + log.info("creating ssh session"); + jSch.addIdentity(privateKey); // 개인키 + session = jSch.getSession(user, remoteJumpHost, sshPort); // 세션 설정 + Properties config = new Properties(); + config.put("StrictHostKeyChecking", "no"); + session.setConfig(config); + log.info("complete creating ssh session"); + + log.info("start connecting ssh connection"); + session.connect(); // ssh 연결 + log.info("success connecting ssh connection "); + + // 로컬pc의 남는 포트 하나와 원격 접속한 pc의 db포트 연결 + log.info("start forwarding"); + forwardedPort = session.setPortForwardingL(0, databaseUrl, databasePort); + log.info("successfully connected to database"); + + } catch (Exception e){ + log.error("fail to make ssh tunneling"); + this.closeSSH(); + e.printStackTrace(); + exit(1); + } + + return forwardedPort; + } +} diff --git a/src/main/java/synk/meeteam/infra/redis/repository/RedisTokenRepository.java b/src/main/java/synk/meeteam/infra/redis/repository/RedisTokenRepository.java index 835f3421..31d9f6e1 100644 --- a/src/main/java/synk/meeteam/infra/redis/repository/RedisTokenRepository.java +++ b/src/main/java/synk/meeteam/infra/redis/repository/RedisTokenRepository.java @@ -2,7 +2,6 @@ import static synk.meeteam.domain.auth.exception.AuthExceptionType.NOT_FOUND_REFRESH_TOKEN; -import java.util.Optional; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; import synk.meeteam.domain.auth.exception.AuthException; @@ -11,12 +10,9 @@ @Repository public interface RedisTokenRepository extends CrudRepository { - Optional findByPlatformId(String platformId); - - default TokenVO findByPlatformIdOrElseThrowException(String platformId) { - return findByPlatformId(platformId) + default TokenVO findByIdOrElseThrow(String userId) { + return findById(userId) .filter(tokenVO -> !tokenVO.isBlack()) - .orElseThrow( - () -> new AuthException(NOT_FOUND_REFRESH_TOKEN)); + .orElseThrow(() -> new AuthException(NOT_FOUND_REFRESH_TOKEN)); } } diff --git a/src/main/java/synk/meeteam/infra/s3/S3FileName.java b/src/main/java/synk/meeteam/infra/s3/S3FileName.java deleted file mode 100644 index ee4fb51c..00000000 --- a/src/main/java/synk/meeteam/infra/s3/S3FileName.java +++ /dev/null @@ -1,15 +0,0 @@ -package synk.meeteam.infra.s3; - -public class S3FileName { - public static final String USER = "user/"; - public static final String PORTFOLIO = "portfolio/"; - - public static String getPortfolioUrl(String encryptedUserId) { - return PORTFOLIO + encryptedUserId + "/"; - } - - public static String getProfileImgUrl(String encryptedUserId) { - return USER + encryptedUserId + "/"; - } - -} diff --git a/src/main/java/synk/meeteam/infra/s3/service/S3Service.java b/src/main/java/synk/meeteam/infra/s3/service/S3Service.java index 6fdd088a..e69de29b 100644 --- a/src/main/java/synk/meeteam/infra/s3/service/S3Service.java +++ b/src/main/java/synk/meeteam/infra/s3/service/S3Service.java @@ -1,89 +0,0 @@ -package synk.meeteam.infra.s3.service; - -import java.time.Duration; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import synk.meeteam.global.entity.ServiceType; -import synk.meeteam.infra.s3.config.S3Config; -import synk.meeteam.infra.s3.service.vo.PreSignedUrlVO; - -@Component -@RequiredArgsConstructor -@Slf4j -public class S3Service { - - private static final Long PRE_SIGNED_URL_EXPIRE_MINUTE = 1L; - private final S3Config s3Config; - @Value("${aws-property.s3-bucket-name}") - private String bucketName; - - public String createPreSignedGetUrl(final String path, final String fileName) { - if (fileName == null) { - return null; - } - String key = path + fileName; - - S3Presigner preSigner = s3Config.getS3Presigner(); - - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(PRE_SIGNED_URL_EXPIRE_MINUTE)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedGetObjectRequest = preSigner.presignGetObject(presignRequest); - log.info("Presigned URL: [{}]", presignedGetObjectRequest.url().toString()); - log.info("HTTP method: [{}]", presignedGetObjectRequest.httpRequest().method()); - - return presignedGetObjectRequest.url().toExternalForm(); - } - - public PreSignedUrlVO getUploadPreSignedUrl(final String prefix, final String fileName, final String extension, - ServiceType serviceType) { - String actualFileName = fileName; - - if (actualFileName == null) { - actualFileName = generateZipFileName(); - } - actualFileName = actualFileName + "." + extension; - - String key = prefix + actualFileName; - - S3Presigner preSigner = s3Config.getS3Presigner(); - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucketName) - .key(key) - .build(); - - PutObjectPresignRequest preSignedUrlRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(PRE_SIGNED_URL_EXPIRE_MINUTE)) - .putObjectRequest(putObjectRequest) - .build(); - - PresignedPutObjectRequest presignedPutObjectRequest = preSigner.presignPutObject(preSignedUrlRequest); - log.info("Presigned URL: [{}]", presignedPutObjectRequest.url().toString()); - log.info("HTTP method: [{}]", presignedPutObjectRequest.httpRequest().method()); - - return PreSignedUrlVO.of(serviceType, actualFileName, presignedPutObjectRequest.url().toExternalForm()); - } - - private String generateZipFileName() { - return UUID.randomUUID().toString(); - } - -} diff --git a/src/main/java/synk/meeteam/security/config/SecurityConfig.java b/src/main/java/synk/meeteam/security/config/SecurityConfig.java index ef775dfa..70c25862 100644 --- a/src/main/java/synk/meeteam/security/config/SecurityConfig.java +++ b/src/main/java/synk/meeteam/security/config/SecurityConfig.java @@ -40,7 +40,7 @@ public class SecurityConfig { "/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**", "/actuator/health", "/skill/**", "/tag/**", "/role/**", - "/user/encrypt/**" + "/user/encrypt/**", "/portfolio/{id}" }; private static final String[] SEMI_AUTH_WHITELIST = { @@ -61,9 +61,11 @@ public WebMvcConfigurer corsConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("https://meeteam.co.kr", "http://localhost:5173", "http://localhost:8080") - .allowedOriginPatterns("https://meeteam.co.kr", "http://localhost:5173", - "http://localhost:8080") + .allowedOrigins("https://meeteam.co.kr", "https://www.meeteam.co.kr", "http://localhost:5173", + "http://localhost:8080", "https://api.meeteam.co.kr", "https://meeteam-phi.vercel.app") + .allowedOriginPatterns("https://meeteam.co.kr", "https://www.meeteam.co.kr", + "http://localhost:5173", + "http://localhost:8080", "https://api.meeteam.co.kr", "https://meeteam-phi.vercel.app") .allowedMethods( HttpMethod.GET.name(), HttpMethod.POST.name(), diff --git a/src/main/java/synk/meeteam/security/filter/JwtAuthenticationFilter.java b/src/main/java/synk/meeteam/security/filter/JwtAuthenticationFilter.java index e798e81c..f2cecc2c 100644 --- a/src/main/java/synk/meeteam/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/synk/meeteam/security/filter/JwtAuthenticationFilter.java @@ -47,8 +47,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throw new AuthException(UNAUTHORIZED_ACCESS_TOKEN); } - String platformId = jwtService.extractPlatformIdFromAccessToken(authorizationAccessToken); - UserDetails userDetails = memberAuthService.loadUserByUsername(platformId); + String encryptedUserId = jwtService.extractUserIdFromAccessToken(authorizationAccessToken); + UserDetails userDetails = memberAuthService.loadUserByUsername(encryptedUserId); Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); diff --git a/src/main/java/synk/meeteam/security/jwt/service/JwtService.java b/src/main/java/synk/meeteam/security/jwt/service/JwtService.java index 8aadc424..78444dfe 100644 --- a/src/main/java/synk/meeteam/security/jwt/service/JwtService.java +++ b/src/main/java/synk/meeteam/security/jwt/service/JwtService.java @@ -23,10 +23,9 @@ import synk.meeteam.domain.auth.dto.response.ReissueUserResponseDto; import synk.meeteam.domain.auth.exception.AuthException; import synk.meeteam.domain.auth.exception.AuthExceptionType; -import synk.meeteam.domain.auth.service.vo.AuthUserVo; +import synk.meeteam.domain.auth.service.vo.AuthUserVO; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.domain.user.user.entity.enums.Authority; -import synk.meeteam.domain.user.user.entity.enums.PlatformType; import synk.meeteam.domain.user.user.repository.UserRepository; import synk.meeteam.global.util.Encryption; import synk.meeteam.infra.redis.repository.RedisTokenRepository; @@ -38,13 +37,8 @@ @Slf4j @Transactional(readOnly = true) public class JwtService { - private static final String AUTH_USER = "memberId"; private static final String BEARER = "Bearer "; - private static final String EMAIL_CLAIM = "email"; - private static final String PLATFORM_ID_CLAIM = "platformId"; - private static final String PLATFORM_TYPE_CLAIM = "platformType"; - - + private static final String USER_ID_CLAIM = "userId"; @Value("${jwt.access.expiration}") private Long accessTokenExpirationPeriod; @@ -66,68 +60,68 @@ public class JwtService { private final AuthUserResponseMapper authUserResponseMapper; @Transactional - public AuthUserResponseDto.login issueToken(AuthUserVo vo) { - String accessToken = jwtTokenProvider.createAccessToken(vo.platformId(), vo.platformType(), accessTokenExpirationPeriod); - + public AuthUserResponseDto.login issueToken(AuthUserVO vo) { if (!vo.authority().equals(Authority.USER)) { throw new AuthException(AuthExceptionType.UNAUTHORIZED_MEMBER_LOGIN); } - String refreshToken = jwtTokenProvider.createRefreshToken(refreshTokenExpirationPeriod); - updateRefreshTokenByPlatformId(vo.platformId(), refreshToken); + String accessToken = jwtTokenProvider.createAccessToken(Encryption.encryptLong(vo.userId()), + accessTokenExpirationPeriod); + String refreshToken = jwtTokenProvider.createRefreshToken(Encryption.encryptLong(vo.userId()), + refreshTokenExpirationPeriod); + updateRefreshTokenByUserId(Encryption.encryptLong(vo.userId()), refreshToken); return authUserResponseMapper.ofLogin(vo.authType(), vo.authority(), Encryption.encryptLong(vo.userId()), - vo.nickname(), vo.pictureUrl(), accessToken, refreshToken); + vo.nickname(), vo.profileImgUrl(), vo.universityName(), accessToken, refreshToken); } @Transactional public ReissueUserResponseDto reissueToken(HttpServletRequest request) { - String refreshToken = extractRefreshToken(request); String accessToken = extractAccessToken(request); + String refreshToken = extractRefreshToken(request); if (!validateToken(refreshToken)) { throw new AuthException(INVALID_REFRESH_TOKEN); } Claims tokenClaims = jwtTokenProvider.getTokenClaims(accessToken); - TokenVO foundRefreshToken = redisTokenRepository.findByPlatformIdOrElseThrowException( - String.valueOf(tokenClaims.get(PLATFORM_ID_CLAIM))); + String foundRefreshToken = redisTokenRepository + .findByIdOrElseThrow((String) tokenClaims.get(USER_ID_CLAIM)).getRefreshToken(); - if (!foundRefreshToken.getRefreshToken().equals(refreshToken)) { + if (foundRefreshToken == null || !foundRefreshToken.equals(refreshToken)) { throw new AuthException(INVALID_REFRESH_TOKEN); } - String platformId = (String) tokenClaims.get(PLATFORM_ID_CLAIM); - PlatformType platformType = (PlatformType) tokenClaims.get(PLATFORM_TYPE_CLAIM); + String encryptedUserId = (String) tokenClaims.get(USER_ID_CLAIM); - String newAccessToken = jwtTokenProvider.createAccessToken(platformId, platformType, accessTokenExpirationPeriod); - String newRefreshToken = jwtTokenProvider.createRefreshToken(refreshTokenExpirationPeriod); + String newAccessToken = jwtTokenProvider.createAccessToken(encryptedUserId, accessTokenExpirationPeriod); + String newRefreshToken = jwtTokenProvider.createRefreshToken(encryptedUserId, refreshTokenExpirationPeriod); - updateRefreshTokenByPlatformId(platformId, newRefreshToken); + updateRefreshTokenByUserId(encryptedUserId, newRefreshToken); - return ReissueUserResponseDto.of(platformId, newAccessToken, newRefreshToken); + return ReissueUserResponseDto.of(encryptedUserId, newAccessToken, newRefreshToken); } @Transactional - public void logout(User user){ - TokenVO foundRefreshToken = redisTokenRepository.findByPlatformIdOrElseThrowException(user.getPlatformId()); + public void logout(User user) { + TokenVO foundRefreshToken = redisTokenRepository.findByIdOrElseThrow(user.getEncryptUserId()); foundRefreshToken.updateRefreshToken(null); redisTokenRepository.save(foundRefreshToken); } - public String extractPlatformIdFromAccessToken(final String atk) throws JsonProcessingException { + public String extractUserIdFromAccessToken(final String atk) throws JsonProcessingException { Claims tokenClaims = jwtTokenProvider.getTokenClaims(atk); - return jwtTokenProvider.getPlatformIdFromClaim(tokenClaims, PLATFORM_ID_CLAIM); + return jwtTokenProvider.getPlatformIdFromClaim(tokenClaims, USER_ID_CLAIM); } - public Boolean validateToken(final String atk) { + public Boolean validateToken(final String accessToken) { try { - Claims tokenClaims = jwtTokenProvider.getTokenClaims(atk); + Claims tokenClaims = jwtTokenProvider.getTokenClaims(accessToken); return !tokenClaims.getExpiration().before(new Date()); } catch (MalformedJwtException e) { throw new AuthException(INVALID_ACCESS_TOKEN); - } catch (ExpiredJwtException e){ + } catch (ExpiredJwtException e) { throw new AuthException(UNAUTHORIZED_ACCESS_TOKEN); } } @@ -146,16 +140,14 @@ private String extractAccessToken(HttpServletRequest request) { .orElseThrow(() -> new AuthException(INVALID_ACCESS_TOKEN)); } - - - public void updateRefreshTokenByPlatformId(String platformId, String newRefreshToken) { - redisTokenRepository.findByPlatformId(String.valueOf(platformId)) + public void updateRefreshTokenByUserId(String encryptedUserId, String newRefreshToken) { + redisTokenRepository.findById(encryptedUserId) .ifPresent(refreshToken -> { refreshToken.updateBlack(true); }); - log.info("newRefreshToken = {}", newRefreshToken); + log.debug("newRefreshToken = {}", newRefreshToken); redisTokenRepository.save(TokenVO.builder() - .platformId(platformId) + .userId(encryptedUserId) .isBlack(false) .refreshToken(newRefreshToken) .build()); diff --git a/src/main/java/synk/meeteam/security/jwt/service/JwtTokenProvider.java b/src/main/java/synk/meeteam/security/jwt/service/JwtTokenProvider.java index 41fe7743..d8e5982f 100644 --- a/src/main/java/synk/meeteam/security/jwt/service/JwtTokenProvider.java +++ b/src/main/java/synk/meeteam/security/jwt/service/JwtTokenProvider.java @@ -12,18 +12,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import synk.meeteam.domain.user.user.entity.enums.PlatformType; @Slf4j @Component public class JwtTokenProvider { private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; - private static final String EMAIL_CLAIM = "email"; - private static final String PLATFORM_ID_CLAIM = "platformId"; - private static final String PLATFORM_TYPE_CLAIM = "platformType"; - - + private static final String USER_ID_CLAIM = "userId"; private final Key key; private final ObjectMapper objectMapper; @@ -35,24 +30,24 @@ public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey, ObjectMappe this.objectMapper = objectMapper; } - public String createAccessToken(String platformId, PlatformType platformType, Long expirationTime) { + public String createAccessToken(String userId, Long expirationTime) { Date now = new Date(); return Jwts.builder() .setSubject(ACCESS_TOKEN_SUBJECT) - .claim(PLATFORM_ID_CLAIM, platformId) - .claim(PLATFORM_TYPE_CLAIM, platformType) + .claim(USER_ID_CLAIM, userId) .setIssuedAt(now) //토큰 발행 시간 정보 .setExpiration(new Date(now.getTime() + expirationTime)) //토큰 만료 시간 설정 .signWith(key, SignatureAlgorithm.HS256) .compact(); } - public String createRefreshToken(Long expirationTime) { + public String createRefreshToken(String userId, Long expirationTime) { Date now = new Date(); return Jwts.builder() .setSubject(REFRESH_TOKEN_SUBJECT) + .claim(USER_ID_CLAIM, userId) .setIssuedAt(now) //토큰 발행 시간 정보 .setExpiration(new Date(now.getTime() + expirationTime)) //토큰 만료 시간 설정 .signWith(key, SignatureAlgorithm.HS256) @@ -68,13 +63,6 @@ public Claims getTokenClaims(final String token) { .getBody(); } - public Claims makeInfoToClaim(String infoName, final Long memberId) throws JsonProcessingException { - String claimValue = objectMapper.writeValueAsString(memberId); - Claims claims = Jwts.claims(); - claims.put(infoName, claimValue); - return claims; - } - public String getPlatformIdFromClaim(Claims claims, String infoName) throws JsonProcessingException { return claims.get(infoName, String.class); diff --git a/src/main/java/synk/meeteam/security/jwt/service/vo/TokenVO.java b/src/main/java/synk/meeteam/security/jwt/service/vo/TokenVO.java index 99fda436..072ccad2 100644 --- a/src/main/java/synk/meeteam/security/jwt/service/vo/TokenVO.java +++ b/src/main/java/synk/meeteam/security/jwt/service/vo/TokenVO.java @@ -15,7 +15,7 @@ public class TokenVO { @Id @Indexed - private String platformId; + private String userId; private boolean isBlack; @@ -31,8 +31,8 @@ public void updateBlack(boolean black) { } @Builder - public TokenVO(String platformId, boolean isBlack, String refreshToken) { - this.platformId = platformId; + public TokenVO(String userId, boolean isBlack, String refreshToken) { + this.userId = userId; this.isBlack = isBlack; this.refreshToken = refreshToken; } diff --git a/src/main/java/synk/meeteam/security/service/MemberAuthService.java b/src/main/java/synk/meeteam/security/service/MemberAuthService.java index c6fa01e4..628fe375 100644 --- a/src/main/java/synk/meeteam/security/service/MemberAuthService.java +++ b/src/main/java/synk/meeteam/security/service/MemberAuthService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.domain.user.user.repository.UserRepository; +import synk.meeteam.global.util.Encryption; import synk.meeteam.security.CustomAuthUser; @Service @@ -16,8 +17,8 @@ public class MemberAuthService implements UserDetailsService { private final UserRepository userRepository; @Override - public UserDetails loadUserByUsername(String platformId) throws UsernameNotFoundException { - User user = userRepository.findByPlatformIdOrElseThrowException(platformId); + public UserDetails loadUserByUsername(String encryptedUserId) throws UsernameNotFoundException { + User user = userRepository.findByIdOrElseThrow(Encryption.decryptLong(encryptedUserId)); return new CustomAuthUser(user, user.getAuthority()); } diff --git a/src/main/resources/file-error-appender.xml b/src/main/resources/file-error-appender.xml new file mode 100644 index 00000000..0ae58956 --- /dev/null +++ b/src/main/resources/file-error-appender.xml @@ -0,0 +1,19 @@ + + + ./log/error/error-${BY_DATE}.log + + ERROR + ACCEPT + DENY + + + ${LOG_PATTERN} + + + ./backup/error/error-%d{yyyy-MM-dd}.%i.log + 100MB + 10 + 1GB + + + \ No newline at end of file diff --git a/src/main/resources/file-info-appender.xml b/src/main/resources/file-info-appender.xml new file mode 100644 index 00000000..53f917fd --- /dev/null +++ b/src/main/resources/file-info-appender.xml @@ -0,0 +1,19 @@ + + + ./log/info/info-${BY_DATE}.log + + INFO + ACCEPT + DENY + + + ${LOG_PATTERN} + + + ./backup/info/info-%d{yyyy-MM-dd}.%i.log + 100MB + 10 + 1GB + + + \ No newline at end of file diff --git a/src/main/resources/file-warn-appender.xml b/src/main/resources/file-warn-appender.xml new file mode 100644 index 00000000..cabfcea0 --- /dev/null +++ b/src/main/resources/file-warn-appender.xml @@ -0,0 +1,19 @@ + + + ./log/warn/warn-${BY_DATE}.log + + WARN + ACCEPT + DENY + + + ${LOG_PATTERN} + + + ./backup/warn/warn-%d{yyyy-MM-dd}.%i.log + 100MB + 10 + 1GB + + + \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 99867d0e..08c6f36c 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,17 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/src/test/java/synk/meeteam/domain/common/skill/SkillRepositoryTest.java b/src/test/java/synk/meeteam/domain/common/skill/SkillRepositoryTest.java index bb73cffe..d9d13033 100644 --- a/src/test/java/synk/meeteam/domain/common/skill/SkillRepositoryTest.java +++ b/src/test/java/synk/meeteam/domain/common/skill/SkillRepositoryTest.java @@ -58,8 +58,8 @@ public class SkillRepositoryTest { List skills = skillRepository.findAllByKeywordAndTopLimit(keyword, limit); //then - assertThat(skills).extracting("name").containsExactly("next.js", "node.js"); - assertThat(skills.size()).isEqualTo(2); + assertThat(skills).extracting("name").containsExactly("kotlin", "next.js", "node.js", "python", "spring"); + assertThat(skills.size()).isEqualTo(5); } @Test diff --git a/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioFixture.java b/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioFixture.java index bfbea0fa..b9aba173 100644 --- a/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioFixture.java +++ b/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioFixture.java @@ -7,7 +7,7 @@ import org.springframework.data.domain.SliceImpl; import synk.meeteam.domain.common.field.entity.Field; import synk.meeteam.domain.common.role.entity.Role; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.dto.response.GetUserPortfolioResponseDto; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.global.dto.SliceInfo; @@ -16,40 +16,18 @@ public class PortfolioFixture { public static List createPortfolioFixtures_1_2() { return List.of( - new Portfolio(1L, "타이틀1", "디스크립션1", true, 1), - new Portfolio(2L, "타이틀2", "디스크립션2", true, 2) + Portfolio.builder().id(1L).title("타이틀1").description("디스크립션1").isPin(true).pinOrder(1).build(), + Portfolio.builder().id(2L).title("타이틀2").description("디스크립션2").isPin(true).pinOrder(2).build() ); } public static List createPortfolioFixtures_2_1() { return List.of( - new Portfolio(2L, "타이틀2", "디스크립션2", true, 2), - new Portfolio(1L, "타이틀1", "디스크립션1", true, 1) + Portfolio.builder().id(2L).title("타이틀2").description("디스크립션2").isPin(true).pinOrder(2).build(), + Portfolio.builder().id(1L).title("타이틀1").description("디스크립션1").isPin(true).pinOrder(1).build() ); } - public static Portfolio createUserPortfolio(String title, boolean isPin, int pinOrder, Long createdBy, - LocalDate start, Role role, - Field field) { - Portfolio portfolio = Portfolio.builder() - .title(title) - .content("컨텐츠") - .description("디스크립션") - .proceedStart(start) - .proceedEnd(LocalDate.now()) - .proceedType(ProceedType.ON_LINE) - .isPin(isPin) - .pinOrder(pinOrder) - .mainImageFileName("이미지.png") - .zipFileName("집.zip") - .fileOrder(List.of("집1", "집2")) - .role(role) - .field(field) - .build(); - portfolio.setCreatedBy(createdBy); - return portfolio; - } - public static Portfolio createPortfolioFixture() { return Portfolio.builder() .id(1L) @@ -69,11 +47,11 @@ public static Portfolio createPortfolioFixture() { .build(); } - public static Slice createSlicePortfolioDtos() { + public static Slice createSlicePortfolioDtos() { return new SliceImpl<>( List.of( - new GetProfilePortfolioDto(1L, "타이틀1", "이미지url", "개발", "개발자", true, 1), - new GetProfilePortfolioDto(2L, "타이틀2", "이미지url", "개발", "개발자", true, 2) + new SimplePortfolioDto(1L, "타이틀1", "이미지url", "개발", "개발자", true, 1), + new SimplePortfolioDto(2L, "타이틀2", "이미지url", "개발", "개발자", true, 2) ), PageRequest.of(1, 12), false @@ -83,8 +61,8 @@ public static Slice createSlicePortfolioDtos() { public static GetUserPortfolioResponseDto createUserAllPortfolios() { return new GetUserPortfolioResponseDto( List.of( - new GetProfilePortfolioDto(1L, "타이틀1", "이미지url", "개발", "개발자", true, 1), - new GetProfilePortfolioDto(2L, "타이틀2", "이미지url", "개발", "개발자", true, 2) + new SimplePortfolioDto(1L, "타이틀1", "이미지url", "개발", "개발자", true, 1), + new SimplePortfolioDto(2L, "타이틀2", "이미지url", "개발", "개발자", true, 2) ), new SliceInfo(1, 12, false) ); diff --git a/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioRepositoryTest.java b/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioRepositoryTest.java index 06bd8532..d207ae81 100644 --- a/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioRepositoryTest.java +++ b/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioRepositoryTest.java @@ -11,11 +11,12 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; import synk.meeteam.domain.common.field.entity.Field; import synk.meeteam.domain.common.field.repository.FieldRepository; import synk.meeteam.domain.common.role.entity.Role; import synk.meeteam.domain.common.role.repository.RoleRepository; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.portfolio.portfolio.entity.Portfolio; import synk.meeteam.domain.portfolio.portfolio.repository.PortfolioRepository; import synk.meeteam.domain.user.user.entity.User; @@ -24,6 +25,7 @@ @DataJpaTest @ActiveProfiles("test") +@Sql({"classpath:test-portfolio.sql"}) public class PortfolioRepositoryTest { @Autowired @@ -44,14 +46,6 @@ public class PortfolioRepositoryTest { void setup() { role = roleRepository.findById(1L).get(); field = fieldRepository.findById(1L).get(); - - List portfolios = List.of( - PortfolioFixture.createUserPortfolio("타이틀1", true, 1, 1L, LocalDate.of(2024, 1, 2), role, field), - PortfolioFixture.createUserPortfolio("타이틀2", true, 2, 1L, LocalDate.of(2024, 1, 3), role, field), - PortfolioFixture.createUserPortfolio("타이틀3", false, 3, 1L, LocalDate.of(2024, 1, 4), role, field), - PortfolioFixture.createUserPortfolio("타이틀4", true, 1, 2L, LocalDate.of(2024, 1, 4), role, field) - ); - portfolioRepository.saveAllAndFlush(portfolios); } @Test @@ -69,7 +63,6 @@ void setup() { List.of(1L, 2L)); //then assertThat(userPortfolios).extracting("title").containsExactly("타이틀1", "타이틀2"); - } @Test @@ -78,7 +71,7 @@ void setup() { User user = userRepository.findById(1L).get(); int page = 1; //when - Slice userPortfolios = portfolioRepository.findUserPortfoliosByUserOrderByCreatedAtDesc( + Slice userPortfolios = portfolioRepository.findSlicePortfoliosByUserOrderByCreatedAtDesc( PageRequest.of(page - 1, 12), user); //then assertThat(userPortfolios.hasNext()).isEqualTo(false); @@ -89,9 +82,10 @@ void setup() { @Test void 포트폴리오등록_등록성공() { //given - Portfolio portfolio = new Portfolio("Meeteam", - "대학생 맞춤형 구인 포트폴리오 서비스", - "밋팀(Meeteam)은 나 자신을 의미하는 Me, 팀을 의미하는 Team, 만남을 의미하는 Meet이 합쳐진 단어입니다.\n" + Portfolio portfolio = Portfolio.builder() + .title("Meeteam") + .description("대학생 맞춤형 구인 포트폴리오 서비스") + .content("밋팀(Meeteam)은 나 자신을 의미하는 Me, 팀을 의미하는 Team, 만남을 의미하는 Meet이 합쳐진 단어입니다.\n" + "대학생들의 보다 원활한 팀프로젝트를 위해 기획하게 되었습니다.\n" + "\n" + "\n" @@ -100,13 +94,16 @@ void setup() { + "\n" + "이를 위해 함께 멋진 서비스를 완성할 웹 디자이너를 찾고 있어요!\n" + "밋팀(Meeteam)은 나 자신을 의미하는 Me, 팀을 의미하는 Team, 만남을 의미하는 Meet이 합쳐진 단어입니다.\n" - + "대학생들의 보다 원활한 팀프로젝트를 위해 기획하게 되었으며, 그 외에 포토폴리오로서의 기능까지 생각하고 있습니다!\n", - LocalDate.of(2023, 11, 2), - LocalDate.of(2024, 3, 15), - ProceedType.ON_LINE, field, role, - "sdkfjldkfcjemqq.png", - "sdkflsdfjfklwemq.zip", - List.of("이미지1.png", "이미지2.png")); + + "대학생들의 보다 원활한 팀프로젝트를 위해 기획하게 되었으며, 그 외에 포토폴리오로서의 기능까지 생각하고 있습니다!\n") + .proceedStart(LocalDate.of(2023, 11, 2)) + .proceedEnd(LocalDate.of(2024, 3, 15)) + .proceedType(ProceedType.ON_LINE) + .field(field) + .role(role) + .mainImageFileName("sdkfjldkfcjemqq.png") + .zipFileName("sdkflsdfjfklwemq.zip") + .fileOrder(List.of("이미지1.png", "이미지2.png")) + .build(); //when Portfolio savedPortfolio = portfolioRepository.saveAndFlush(portfolio); diff --git a/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioServiceTest.java b/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioServiceTest.java index b463a146..8609164b 100644 --- a/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioServiceTest.java +++ b/src/test/java/synk/meeteam/domain/portfolio/portfolio/PortfolioServiceTest.java @@ -31,6 +31,7 @@ import synk.meeteam.domain.portfolio.portfolio.service.PortfolioServiceImpl; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.entity.ProceedType; +import synk.meeteam.infra.aws.service.CloudFrontService; @ExtendWith(MockitoExtension.class) public class PortfolioServiceTest { @@ -49,8 +50,12 @@ public class PortfolioServiceTest { @Mock private RoleRepository roleRepository; + @Mock + private CloudFrontService cloudFrontService; + @BeforeEach void setup() { + portfolioService = portfolioServiceImpl; } @@ -109,9 +114,10 @@ void setup() { void 내포트폴리오목록조회_목록조회성공() { //given doReturn(PortfolioFixture.createSlicePortfolioDtos()).when(portfolioRepository) - .findUserPortfoliosByUserOrderByCreatedAtDesc(eq(PageRequest.of(0, 12)), any()); + .findSlicePortfoliosByUserOrderByCreatedAtDesc(eq(PageRequest.of(0, 12)), any()); + doReturn("url입니다.").when(cloudFrontService).getSignedUrl(any(), any()); //when - GetUserPortfolioResponseDto userAllPortfolios = portfolioService.getMyAllPortfolio(1, 12, + GetUserPortfolioResponseDto userAllPortfolios = portfolioService.getSliceMyAllPortfolio(1, 12, User.builder().build()); //then diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantServiceTest.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantServiceTest.java index 0b05d530..8515690d 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantServiceTest.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantServiceTest.java @@ -34,7 +34,7 @@ import synk.meeteam.domain.user.user.UserFixture; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.util.Encryption; -import synk.meeteam.infra.s3.service.S3Service; +import synk.meeteam.infra.aws.service.CloudFrontService; @ExtendWith(MockitoExtension.class) @ActiveProfiles("test") @@ -46,7 +46,7 @@ public class RecruitmentApplicantServiceTest { private RecruitmentApplicantRepository recruitmentApplicantRepository; @Mock - private S3Service s3Service; + private CloudFrontService cloudFrontService; @Test void 구인신청_성공() { @@ -318,13 +318,16 @@ public class RecruitmentApplicantServiceTest { RecruitmentApplicant applicant1 = RecruitmentApplicantFixture.createRecruitmentApplicant( recruitmentPost, user1, role1); + applicant1.setId(1L); RecruitmentApplicant applicant2 = RecruitmentApplicantFixture.createRecruitmentApplicant( recruitmentPost, user2, role2); + applicant2.setId(2L); doReturn(2L).when(recruitmentApplicantRepository).updateRecruitStatus(any(), any()); + doReturn(List.of(applicant1, applicant2)).when(recruitmentApplicantRepository).findAllInApplicantId(any()); // when, then - recruitmentApplicantService.rejectApplicants(List.of(applicant1, applicant2), List.of(userId1, userId2), + recruitmentApplicantService.rejectApplicants(List.of(userId1, userId2), userId1); } @@ -347,13 +350,16 @@ public class RecruitmentApplicantServiceTest { RecruitmentApplicant applicant1 = RecruitmentApplicantFixture.createRecruitmentApplicant( recruitmentPost, user1, role1); + applicant1.setId(1L); RecruitmentApplicant applicant2 = RecruitmentApplicantFixture.createRecruitmentApplicant( recruitmentPost, user2, role2); + applicant2.setId(2L); + + doReturn(List.of(applicant1, applicant2)).when(recruitmentApplicantRepository).findAllInApplicantId(any()); // when, then Assertions.assertThatThrownBy( - () -> recruitmentApplicantService.rejectApplicants(List.of(applicant1, applicant2), - List.of(userId1, userId2), userId1)) + () -> recruitmentApplicantService.rejectApplicants(List.of(userId1, userId2), userId1)) .isInstanceOf(RecruitmentApplicantException.class) .hasMessageContaining(INVALID_USER.message()); } @@ -376,13 +382,17 @@ public class RecruitmentApplicantServiceTest { RecruitmentApplicant applicant1 = RecruitmentApplicantFixture.createApprovedRecruitmentApplicant( recruitmentPost, user1, role1); + applicant1.setId(1L); + RecruitmentApplicant applicant2 = RecruitmentApplicantFixture.createApprovedRecruitmentApplicant( recruitmentPost, user2, role2); + applicant2.setId(2L); + + doReturn(List.of(applicant1, applicant2)).when(recruitmentApplicantRepository).findAllInApplicantId(any()); // when, then Assertions.assertThatThrownBy( - () -> recruitmentApplicantService.rejectApplicants(List.of(applicant1, applicant2), - List.of(userId1, userId2), userId1)) + () -> recruitmentApplicantService.rejectApplicants(List.of(userId1, userId2), userId1)) .isInstanceOf(RecruitmentApplicantException.class) .hasMessageContaining(ALREADY_PROCESSED_APPLICANT.message()); } @@ -405,11 +415,13 @@ public class RecruitmentApplicantServiceTest { RecruitmentApplicant applicant1 = RecruitmentApplicantFixture.createRecruitmentApplicant( recruitmentPost, user1, role1); + applicant1.setId(1L); + + doReturn(List.of(applicant1)).when(recruitmentApplicantRepository).findAllInApplicantId(any()); // when, then Assertions.assertThatThrownBy( - () -> recruitmentApplicantService.rejectApplicants(List.of(applicant1), - List.of(userId1, userId2), userId1)) + () -> recruitmentApplicantService.rejectApplicants(List.of(userId1, userId2), userId1)) .isInstanceOf(RecruitmentApplicantException.class) .hasMessageContaining(INVALID_REQUEST.message()); } @@ -433,13 +445,14 @@ public class RecruitmentApplicantServiceTest { false )).when(recruitmentApplicantRepository).findByPostIdAndRoleId(any(), any(), any()); - doReturn("이미지입니다").when(s3Service).createPreSignedGetUrl(any(), any()); + doReturn("이미지입니다").when(cloudFrontService).getSignedUrl(any(), any()); try (MockedStatic utilities = Mockito.mockStatic(Encryption.class)) { utilities.when(() -> Encryption.encryptLong(any())).thenReturn("1234"); // when - GetApplicantResponseDto responseDtos = recruitmentApplicantService.getAllByRole(postId, roleId, 1, 12); + GetApplicantResponseDto responseDtos = recruitmentApplicantService.getAllByRole(postId, roleId, 1L, 1L, 1, + 12); // then Assertions.assertThat(responseDtos.applicants().size()).isEqualTo(2); diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantTest.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantTest.java index e0837ecc..e26684a3 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantTest.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_applicant/RecruitmentApplicantTest.java @@ -42,8 +42,8 @@ public class RecruitmentApplicantTest { private String accessHeader; @Value("${jwt.refresh.header}") private String refreshHeader; - private String TOKEN = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInBsYXRmb3JtSWQiOiJEaTdsQ2hNR3hqWlZUYWk2ZDc2SG8xWUxEVV94TDh0bDFDZmRQTVY1U1FNIiwicGxhdGZvcm1UeXBlIjoiTkFWRVIiLCJpYXQiOjE3MDg1OTkyMTMsImV4cCI6MTgxNjU5OTIxM30.C9Rt8t2dM_9pmUIwyMiRwi2kZSXAFVJnjAPj2rTbQtw"; - private String TOKEN_OTHER = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInBsYXRmb3JtSWQiOiJEaTdsQ2hNR3hqWlZUYWk2ZDc2SG8xWUxEVV94TDh0bDFDZmRQTVY1U1ExIiwicGxhdGZvcm1UeXBlIjoiTkFWRVIiLCJpYXQiOjE3MDg1OTkyMTMsImV4cCI6MTgxNjU5OTIxM30.ujVS6-qGhaOYJv0aPet3tgcc5iN93-k0Kv9w1rETFpA"; + private String TOKEN = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6IjRPYVZFNDIxRFN3UjYzeGZLZjZ2eEEiLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.PsQHWlh-tV-FY3dk0zVwiiBCfyLn4LPbFylGcau1Eis"; + private String TOKEN_OTHER = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6ImJfVmtjT05oTUNKSWJHbEQ2eW9Ua3ciLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.pGrBWCYOrR2RKQfqKgG705I7NHqIlykUcYrKqhj_nOM"; @Autowired private TestRestTemplate restTemplate; @Autowired diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_comment/RecruitmentCommentServiceTest.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_comment/RecruitmentCommentServiceTest.java index 02dbf010..71f3ee4c 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_comment/RecruitmentCommentServiceTest.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_comment/RecruitmentCommentServiceTest.java @@ -25,7 +25,7 @@ import synk.meeteam.domain.recruitment.recruitment_post.RecruitmentPostFixture; import synk.meeteam.domain.recruitment.recruitment_post.dto.response.GetCommentResponseDto; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; -import synk.meeteam.infra.s3.service.S3Service; +import synk.meeteam.infra.aws.service.CloudFrontService; @ExtendWith(MockitoExtension.class) public class RecruitmentCommentServiceTest { @@ -37,7 +37,7 @@ public class RecruitmentCommentServiceTest { private RecruitmentCommentRepository recruitmentCommentRepository; @Mock - private S3Service s3Service; + private CloudFrontService cloudFrontService; @Test void 댓글조회_댓글그룹반환() { @@ -50,7 +50,7 @@ public class RecruitmentCommentServiceTest { doReturn(recruitmentComments).when(recruitmentCommentRepository) .findAllByRecruitmentId(recruitmentPost.getId()); - doReturn("임시 url").when(s3Service).createPreSignedGetUrl(any(), any()); + doReturn("임시 url").when(cloudFrontService).getSignedUrl(any(), any()); // when List getCommentResponseDtos = recruitmentCommentService.getRecruitmentComments( diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostEntityTest.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostEntityTest.java index 740ed648..9f63bc2a 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostEntityTest.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostEntityTest.java @@ -50,7 +50,7 @@ public class RecruitmentPostEntityTest { // when recruitmentPost.updateRecruitmentPost("정상제목2", "내용", Scope.ON_CAMPUS, Category.PROJECT, new Field(1L, "개발"), ProceedType.ON_LINE, LocalDate.of(2024, 1, 5), LocalDate.of(2024, 1, 15), LocalDate.of(2024, 1, 15), 5L, - "kakaolink~~", false, null, 5L, 5L); + "kakaolink~~", false, null, 5L, 5L, 1L); // then Assertions.assertThat(recruitmentPost) diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostFixture.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostFixture.java index b1acb10d..a12276ef 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostFixture.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostFixture.java @@ -6,7 +6,7 @@ import org.springframework.data.domain.PageImpl; import synk.meeteam.domain.common.field.entity.Field; import synk.meeteam.domain.recruitment.recruitment_post.dto.response.PaginationSearchPostResponseDto; -import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SearchRecruitmentPostDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.SimpleRecruitmentPostDto; import synk.meeteam.domain.recruitment.recruitment_post.entity.RecruitmentPost; import synk.meeteam.domain.recruitment.recruitment_post.repository.vo.RecruitmentPostVo; import synk.meeteam.global.dto.PageInfo; @@ -51,11 +51,13 @@ public static RecruitmentPost createRecruitmentPost_bookmark(Long bookmarkCount) } public static PaginationSearchPostResponseDto createPageSearchPostResponseDto() { - List searchRecruitmentPostDtos = List.of( - new SearchRecruitmentPostDto(1L, "제목", "프로젝트", "작성자", "이미지", "2022-03-03", "교외", true), - new SearchRecruitmentPostDto(2L, "제목2", "스터디", "작성자", "이미지", "2022-03-03", "교내", false) + List simpleRecruitmentPostDtos = List.of( + new SimpleRecruitmentPostDto(1L, "제목", "프로젝트", "dfjksdewnknv", "작성자", "dfjksdewnknv.png", "2022-03-03", + "교외", true, false), + new SimpleRecruitmentPostDto(2L, "제목2", "스터디", "sdfjkldfwel", "작성자", "dfjksdewnknv.png", "2022-03-03", + "교내", false, false) ); - return new PaginationSearchPostResponseDto(searchRecruitmentPostDtos, new PageInfo(1, 24, 3L, 1)); + return new PaginationSearchPostResponseDto(simpleRecruitmentPostDtos, new PageInfo(1, 24, 3L, 1)); } public static Page createPagePostVo() { diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostRepositoryTest.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostRepositoryTest.java index 3e0eb00e..cfa660f8 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostRepositoryTest.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostRepositoryTest.java @@ -1,5 +1,6 @@ package synk.meeteam.domain.recruitment.recruitment_post; +import static org.assertj.core.api.Assertions.assertThat; import static synk.meeteam.domain.recruitment.recruitment_post.RecruitmentPostFixture.TITLE_EXCEED_40; import jakarta.validation.ConstraintViolationException; @@ -29,7 +30,7 @@ public class RecruitmentPostRepositoryTest { savedRecruitmentPost.getId()).orElse(null); // then - Assertions.assertThat(foundRecruitmentPost) + assertThat(foundRecruitmentPost) .extracting("title", "content", "scope", "category", "field", "proceedType", "proceedingStart", "proceedingEnd", "deadline") .containsExactly(recruitmentPost.getTitle(), recruitmentPost.getContent(), recruitmentPost.getScope(), @@ -60,4 +61,14 @@ public class RecruitmentPostRepositoryTest { }).isInstanceOf(ConstraintViolationException.class); } + @Test + void 마감일지난구인글마감_마감성공() { + //when + recruitmentPostRepository.updateIsCloseTrue(); + + //then + RecruitmentPost recruitmentPost = recruitmentPostRepository.findByIdOrElseThrow(1L); + + assertThat(recruitmentPost.isClosed()).isTrue(); + } } diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostServiceTest.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostServiceTest.java index 1de98c0b..29fd1768 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostServiceTest.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostServiceTest.java @@ -115,12 +115,12 @@ public class RecruitmentPostServiceTest { // given RecruitmentPost srcRecruitmentPost = RecruitmentPostFixture.createRecruitmentPost("수정하려는제목입니다"); RecruitmentPost dstRecruitmentPost = RecruitmentPostFixture.createRecruitmentPost("그냥제목입니다"); - + dstRecruitmentPost.setCreatedBy(1L); doReturn(dstRecruitmentPost).when(recruitmentPostRepository).save(any()); // when RecruitmentPost newRecruitmentPost = recruitmentPostService.modifyRecruitmentPost(dstRecruitmentPost, - srcRecruitmentPost); + srcRecruitmentPost, 1L); // then assertThat(newRecruitmentPost.getTitle()).isEqualTo("수정하려는제목입니다"); diff --git a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostTest.java b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostTest.java index d6a19dc3..00a40bf5 100644 --- a/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostTest.java +++ b/src/test/java/synk/meeteam/domain/recruitment/recruitment_post/RecruitmentPostTest.java @@ -61,9 +61,8 @@ public class RecruitmentPostTest { @Value("${jwt.refresh.header}") private String refreshHeader; - private String TOKEN = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInBsYXRmb3JtSWQiOiJEaTdsQ2hNR3hqWlZUYWk2ZDc2SG8xWUxEVV94TDh0bDFDZmRQTVY1U1FNIiwicGxhdGZvcm1UeXBlIjoiTkFWRVIiLCJpYXQiOjE3MDg1OTkyMTMsImV4cCI6MTgxNjU5OTIxM30.C9Rt8t2dM_9pmUIwyMiRwi2kZSXAFVJnjAPj2rTbQtw"; - - private String TOKEN_OTHER = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInBsYXRmb3JtSWQiOiJEaTdsQ2hNR3hqWlZUYWk2ZDc2SG8xWUxEVV94TDh0bDFDZmRQTVY1U1ExIiwicGxhdGZvcm1UeXBlIjoiTkFWRVIiLCJpYXQiOjE3MDg1OTkyMTMsImV4cCI6MTgxNjU5OTIxM30.ujVS6-qGhaOYJv0aPet3tgcc5iN93-k0Kv9w1rETFpA"; + private String TOKEN = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6IjRPYVZFNDIxRFN3UjYzeGZLZjZ2eEEiLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.PsQHWlh-tV-FY3dk0zVwiiBCfyLn4LPbFylGcau1Eis"; + private String TOKEN_OTHER = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6ImJfVmtjT05oTUNKSWJHbEQ2eW9Ua3ciLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.pGrBWCYOrR2RKQfqKgG705I7NHqIlykUcYrKqhj_nOM"; @Autowired diff --git a/src/test/java/synk/meeteam/domain/user/user/UserControllerTest.java b/src/test/java/synk/meeteam/domain/user/user/UserControllerTest.java index e1a2ae64..46eade4d 100644 --- a/src/test/java/synk/meeteam/domain/user/user/UserControllerTest.java +++ b/src/test/java/synk/meeteam/domain/user/user/UserControllerTest.java @@ -50,6 +50,9 @@ public class UserControllerTest { private MockMvc mockMvc; private Gson gson; + private String TOKEN = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6IjRPYVZFNDIxRFN3UjYzeGZLZjZ2eEEiLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.PsQHWlh-tV-FY3dk0zVwiiBCfyLn4LPbFylGcau1Eis"; + private String TOKEN_OTHER = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6ImJfVmtjT05oTUNKSWJHbEQ2eW9Ua3ciLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.pGrBWCYOrR2RKQfqKgG705I7NHqIlykUcYrKqhj_nOM"; + @BeforeEach public void init() { GsonBuilder gsonBuilder = new GsonBuilder(); @@ -88,7 +91,7 @@ public void init() { //when final ResultActions resultActions = mockMvc.perform( MockMvcRequestBuilders.put(url) - .header("Authorization", "aaaaa") + .header("Authorization", TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(gson.toJson(UserFixture.createEditProfileDto())) ); @@ -105,7 +108,7 @@ public void init() { //given String encryptedId = "암호화된아이디"; final String url = "/user/profile/" + encryptedId; - doReturn(UserFixture.createReadProfile()).when(profileFacade).readProfile(encryptedId); + doReturn(UserFixture.createReadProfile()).when(profileFacade).readProfile(any(User.class), eq(encryptedId)); //when final ResultActions resultActions = mockMvc.perform( MockMvcRequestBuilders.get(url)); @@ -126,7 +129,7 @@ public void init() { //given final String url = "/user/portfolios"; doReturn(PortfolioFixture.createUserAllPortfolios()).when(portfolioService) - .getMyAllPortfolio(eq(1), eq(12), any()); + .getSliceMyAllPortfolio(eq(1), eq(12), any()); //when final ResultActions resultActions = mockMvc.perform( MockMvcRequestBuilders.get(url) diff --git a/src/test/java/synk/meeteam/domain/user/user/UserFixture.java b/src/test/java/synk/meeteam/domain/user/user/UserFixture.java index 05804b5d..13a094d6 100644 --- a/src/test/java/synk/meeteam/domain/user/user/UserFixture.java +++ b/src/test/java/synk/meeteam/domain/user/user/UserFixture.java @@ -6,7 +6,7 @@ import synk.meeteam.domain.common.department.entity.Department; import synk.meeteam.domain.common.skill.dto.SkillDto; import synk.meeteam.domain.common.university.entity.University; -import synk.meeteam.domain.portfolio.portfolio.dto.GetProfilePortfolioDto; +import synk.meeteam.domain.portfolio.portfolio.dto.SimplePortfolioDto; import synk.meeteam.domain.user.award.dto.GetProfileAwardDto; import synk.meeteam.domain.user.award.dto.UpdateAwardDto; import synk.meeteam.domain.user.user.dto.request.UpdateProfileRequestDto; @@ -41,9 +41,9 @@ public static User createUser() { public static UpdateProfileRequestDto createEditProfileDto() { return new UpdateProfileRequestDto( - "goder", + "goder1", true, - "imageUrl", + "imageUrl.png", "010-1234-5678", true, true, @@ -56,7 +56,7 @@ public static UpdateProfileRequestDto createEditProfileDto() { 4.3, 4.5, List.of(1L, 2L, 3L), - List.of(new UpdateUserLinkDto("naver.com", "네이버")), + List.of(new UpdateUserLinkDto("https://gemini.google.com/", "네이버")), List.of(new UpdateAwardDto("공공데이터공모전", "장려상수상", LocalDate.parse("2023-02-02"), LocalDate.parse("2023-03-01"))), List.of(1L, 2L) @@ -66,6 +66,7 @@ public static UpdateProfileRequestDto createEditProfileDto() { public static GetProfileResponseDto createReadProfile() { return new GetProfileResponseDto( "https://dasfsdf.png", + "dasfdf.png", "민지", "mingi123", true, @@ -81,7 +82,7 @@ public static GetProfileResponseDto createReadProfile() { 4.3, 2019, List.of( - new GetProfilePortfolioDto(1L, "Meeteam 팀을 만나다", "https://~", "개발", "백엔드개발자", true, 1) + new SimplePortfolioDto(1L, "Meeteam 팀을 만나다", "https://~", "개발", "백엔드개발자", true, 1) ), List.of( new GetProfileUserLinkDto("https://~~", "Link"), diff --git a/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java b/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java new file mode 100644 index 00000000..0f8b8a7f --- /dev/null +++ b/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java @@ -0,0 +1,123 @@ +package synk.meeteam.global.xss; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import synk.meeteam.domain.recruitment.recruitment_post.dto.request.CourseTagDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.request.CreateRecruitmentPostRequestDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.request.RecruitmentRoleDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.CreateRecruitmentPostResponseDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.GetRecruitmentPostResponseDto; + + +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ActiveProfiles("test") +public class XssSpringBootTest { + private static final String RECRUITMENT_URL = "/recruitment/postings"; + + @Autowired + private TestRestTemplate restTemplate; + + @Value("${jwt.access.header}") + private String accessHeader; + + private String TOKEN = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6IjRPYVZFNDIxRFN3UjYzeGZLZjZ2eEEiLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.PsQHWlh-tV-FY3dk0zVwiiBCfyLn4LPbFylGcau1Eis"; + private String TOKEN_OTHER = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInVzZXJJZCI6ImJfVmtjT05oTUNKSWJHbEQ2eW9Ua3ciLCJpYXQiOjE3MTQ5ODM5MDQsImV4cCI6MjAyMjk4MzkwNH0.pGrBWCYOrR2RKQfqKgG705I7NHqIlykUcYrKqhj_nOM"; + + HttpHeaders headers; + + + + @BeforeEach + void init() { + restTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory()); + + headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(accessHeader, TOKEN); + } + + @Test + public void escape문자로치환_구인글생성_성공_xss공격() { + HttpEntity request = new HttpEntity(headers); + + String title = "
  • content
  • "; + String expected = "<li>content</li>"; + CreateRecruitmentPostRequestDto requestDto = createRequestDto_title(title); + HttpEntity requestEntity = new HttpEntity<>(requestDto, headers); + + ResponseEntity responseEntity = restTemplate.postForEntity( + RECRUITMENT_URL, + requestEntity, CreateRecruitmentPostResponseDto.class); + + assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()); + CreateRecruitmentPostResponseDto body = responseEntity.getBody(); + assertNotNull(body.recruitmentPostId());; + + ResponseEntity verifyResponseEntity = restTemplate.exchange( + RECRUITMENT_URL + "/{id}", HttpMethod.GET, request, + GetRecruitmentPostResponseDto.class, body.recruitmentPostId()); + + GetRecruitmentPostResponseDto verifyBody = verifyResponseEntity.getBody(); + assertEquals(verifyBody.title(), expected); + } + + @Test + public void escape문자로치환및LocalDate치환_구인글생성_성공_xss공격() { + HttpEntity request = new HttpEntity(headers); + + String title = "
  • content
  • "; + String expected = "<li>content</li>"; + CreateRecruitmentPostRequestDto requestDto = createRequestDto_title(title); + HttpEntity requestEntity = new HttpEntity<>(requestDto, headers); + + ResponseEntity responseEntity = restTemplate.postForEntity( + RECRUITMENT_URL, + requestEntity, CreateRecruitmentPostResponseDto.class); + + assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()); + CreateRecruitmentPostResponseDto body = responseEntity.getBody(); + assertNotNull(body.recruitmentPostId());; + + ResponseEntity verifyResponseEntity = restTemplate.exchange( + RECRUITMENT_URL + "/{id}", HttpMethod.GET, request, + GetRecruitmentPostResponseDto.class, body.recruitmentPostId()); + + GetRecruitmentPostResponseDto verifyBody = verifyResponseEntity.getBody(); + assertEquals(verifyBody.title(), expected); + assertEquals(verifyBody.deadline(), LocalDate.of(2024, 2, 22).toString()); + } + + private CreateRecruitmentPostRequestDto createRequestDto_title(String title) { + CourseTagDto courseTagDto = new CourseTagDto(true, "응소실", "김용혁"); + List recruitmentRoleDtos = new ArrayList<>(); + recruitmentRoleDtos.add(new RecruitmentRoleDto(1L, 1, List.of(1L, 2L, 3L))); + recruitmentRoleDtos.add(new RecruitmentRoleDto(2L, 3, List.of(1L, 2L, 3L))); + recruitmentRoleDtos.add(new RecruitmentRoleDto(3L, 1, List.of(4L, 5L, 6L))); + + return new CreateRecruitmentPostRequestDto("교내", "프로젝트", LocalDate.of(2024, 2, 22), LocalDate.of(2024, 3, 15), + LocalDate.of(2024, 5, 15), 1L, "온라인", + courseTagDto, List.of("웹개발", "AI", "대학생", "구인"), recruitmentRoleDtos, title, "사람구합니당!!"); + } +}