diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e72c072da --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +# top-most EditorConfig file +root = true + +[*] +# [encoding-utf8] +charset = utf-8 + +# [newline-lf] +end_of_line = lf + +# [newline-eof] +insert_final_newline = true + +[*.bat] +end_of_line = crlf + +[*.java] +# [indentation-tab] +indent_style = tab + +# [4-spaces-tab] +indent_size = 4 +tab_width = 4 + +# [no-trailing-spaces] +trim_trailing_whitespace = true + +[line-length-120] +max_line_length = 120 \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..61c3b6f77 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +@kdomo @uwoobeat @uiurihappy diff --git "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" new file mode 100644 index 000000000..e162a0bec --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" @@ -0,0 +1,11 @@ +--- +name: "♻️ refactor" +about: 리팩토링 이슈 템플릿 +title: "♻️ " +labels: "♻️ refactor" +assignees: '' + +--- + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" new file mode 100644 index 000000000..cb9dcf837 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" @@ -0,0 +1,11 @@ +--- +name: "⚙️ chore" +about: 빌드 및 CI/CD 이슈 템플릿 +title: "⚙️ " +labels: "⚙️ chore" +assignees: '' + +--- + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\342\234\205-test.md" "b/.github/ISSUE_TEMPLATE/\342\234\205-test.md" new file mode 100644 index 000000000..d2574fca4 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\205-test.md" @@ -0,0 +1,11 @@ +--- +name: "✅ test" +about: 테스트 이슈 템플릿 +title: "✅ " +labels: "✅ test" +assignees: '' + +--- + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" new file mode 100644 index 000000000..08842a612 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" @@ -0,0 +1,11 @@ +--- +name: "✨ feature" +about: 기능 추가 이슈 템플릿 +title: "✨ " +labels: "✨ feature" +assignees: '' + +--- + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" new file mode 100644 index 000000000..53e9dda59 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" @@ -0,0 +1,11 @@ +--- +name: "🐛 fix" +about: 버그 및 에러 이슈 템플릿 +title: "🐛 " +labels: "🐛 bug/error" +assignees: '' + +--- + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\360\237\223\235-documentation.md" "b/.github/ISSUE_TEMPLATE/\360\237\223\235-documentation.md" new file mode 100644 index 000000000..c85237b51 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\223\235-documentation.md" @@ -0,0 +1,11 @@ +--- +name: "📝 documentation" +about: 문서화 이슈 템플릿 +title: "📝 " +labels: "📝 documentation" +assignees: '' + +--- + +## 📌 Description +- diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..68f377c47 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## 🌱 관련 이슈 +- close # + +## 📌 작업 내용 및 특이사항 +- + +## 📝 참고사항 +- + +## 📚 기타 +- diff --git a/.github/workflows/develop_build_deploy.yml b/.github/workflows/develop_build_deploy.yml new file mode 100644 index 000000000..ca0f02969 --- /dev/null +++ b/.github/workflows/develop_build_deploy.yml @@ -0,0 +1,111 @@ +name: develop Build And Deploy + +on: + push: + branches: [ "develop" ] + +jobs: + build: + runs-on: ubuntu-latest + environment: DEV + strategy: + matrix: + java-version: [ 17 ] + distribution: [ 'temurin' ] + outputs: + # IMAGE_TAG 환경 변수를 다른 Job에서 사용하기 위해 설정 + image-tag: ${{ steps.image-tag.outputs.value }} + steps: + # 기본 체크아웃 + - name: Checkout + uses: actions/checkout@v3 + + # JDK를 17 버전으로 세팅 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: ${{ matrix.java-version }} + distribution: ${{ matrix.distribution }} + + # 이미지 태그 설정 + - name: Set up image-tag by GITHUB_SHA + id: image-tag + run: echo "value=$(echo ${GITHUB_SHA::7})" >> $GITHUB_OUTPUT + + # test 돌릴때 레디스 필요 + - name: Start containers + run: docker-compose -f ./docker-compose-test.yaml up -d + + # Gradlew 실행 허용 + - name: Run chmod to make gradlew executable + run: chmod +x ./gradlew + + # Gradle 빌드 + - name: Build with Gradle + id: gradle + uses: gradle/gradle-build-action@v2 + with: + arguments: | + build + --scan + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + # NCP Container Registry 로그인 + - name: Login to NCP Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.NCP_CONTAINER_REGISTRY }} + username: ${{ secrets.NCP_ACCESS_KEY }} + password: ${{ secrets.NCP_SECRET_KEY }} + + # Docker 이미지 빌드 및 푸시 + - name: Docker Build and Push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ steps.image-tag.outputs.value }} + + # 서버로 docker-compose 파일 전송 + - name: Copy docker-compose.yml to NCP Server + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USERNAME }} + key: ${{ secrets.NCP_PRIVATE_KEY }} + port: ${{ secrets.NCP_PORT }} + source: docker-compose.yaml + target: /home/tenminute/ + + # 슬랙으로 빌드 스캔 결과 전송 + - name: Send to slack + uses: slackapi/slack-github-action@v1.24.0 + with: + payload: | + { + "text": "Gradle Build Scan Report of ${{ github.workflow }}: ${{ steps.gradle.outputs.build-scan-url }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + deploy: + runs-on: ubuntu-latest + environment: DEV + needs: build + steps: + - name: Deploy to NCP Server + uses: appleboy/ssh-action@master + env: + NCP_CONTAINER_REGISTRY: ${{ secrets.NCP_CONTAINER_REGISTRY }} + NCP_IMAGE_TAG: ${{ needs.build.outputs.image-tag }} + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USERNAME }} + key: ${{ secrets.NCP_PRIVATE_KEY }} + port: ${{ secrets.NCP_PORT }} + envs: NCP_CONTAINER_REGISTRY,NCP_IMAGE_TAG # docker-compose.yml 에서 사용할 환경 변수 + script: | + echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}" + docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ env.NCP_IMAGE_TAG }} + docker compose -f /home/tenminute/docker-compose.yaml up -d + docker image prune -a -f diff --git a/.github/workflows/develop_deploy.yml b/.github/workflows/develop_deploy.yml new file mode 100644 index 000000000..85a067fda --- /dev/null +++ b/.github/workflows/develop_deploy.yml @@ -0,0 +1,30 @@ +name: develop Deploy + +on: + workflow_dispatch: + inputs: + commit_hash: + description: 'commit_hash' + required: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: DEV + steps: + - name: Deploy to NCP Server + uses: appleboy/ssh-action@master + env: + NCP_CONTAINER_REGISTRY: ${{ secrets.NCP_CONTAINER_REGISTRY }} + NCP_IMAGE_TAG: ${{ github.event.inputs.commit_hash }} + with: + host: ${{ secrets.NCP_HOST }} + username: tenminute + key: ${{ secrets.NCP_PRIVATE_KEY }} + port: ${{ secrets.NCP_PORT }} + envs: NCP_CONTAINER_REGISTRY,NCP_IMAGE_TAG # docker-compose.yml 에서 사용할 환경 변수 + script: | + echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}" + docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ github.event.inputs.commit_hash }} + docker compose -f /home/tenminute/docker-compose.yaml up -d + docker image prune -a -f diff --git a/.github/workflows/develop_pull_request.yml b/.github/workflows/develop_pull_request.yml new file mode 100644 index 000000000..4ae9b7e58 --- /dev/null +++ b/.github/workflows/develop_pull_request.yml @@ -0,0 +1,45 @@ +name: develop(pull_request) Check Style And Test +on: + pull_request: + branches: + - develop +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 17 ] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + java-version: ${{ matrix.java-version }} + distribution: 'adopt' + + - name: Start containers # test 돌릴때 레디스 필요 + run: docker-compose -f ./docker-compose-test.yaml up -d + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + with: + arguments: check + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + - name: SonarCloud scan + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./gradlew sonar --info --stacktrace diff --git a/.github/workflows/issue_set_merged.yml b/.github/workflows/issue_set_merged.yml new file mode 100644 index 000000000..a9eafee3b --- /dev/null +++ b/.github/workflows/issue_set_merged.yml @@ -0,0 +1,19 @@ +name: Add 'merged' label to closed PR + +on: + pull_request: + types: + - closed + +jobs: + set_merged: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Add 'merged' label to closed PR + run: gh issue edit "$PR_NUMBER" --add-label "merged" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/production_build_deploy.yml b/.github/workflows/production_build_deploy.yml new file mode 100644 index 000000000..2a6f5f13b --- /dev/null +++ b/.github/workflows/production_build_deploy.yml @@ -0,0 +1,112 @@ +name: production Build And Deploy + +on: + push: + tags: + - v*.*.* + +jobs: + build: + runs-on: ubuntu-latest + environment: PROD + strategy: + matrix: + java-version: [ 17 ] + distribution: [ 'temurin' ] + outputs: + # IMAGE_TAG 환경 변수를 다른 Job에서 사용하기 위해 설정 + image-tag: ${{ steps.image-tag.outputs.value }} + steps: + # 기본 체크아웃 + - name: Checkout + uses: actions/checkout@v3 + + # JDK를 17 버전으로 세팅 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: ${{ matrix.java-version }} + distribution: ${{ matrix.distribution }} + + # 이미지 태그 설정 + - name: Set up image-tag by Releases Tag + id: image-tag + run: echo "value=$(cut -d'v' -f2 <<< ${GITHUB_REF#refs/*/})" >> $GITHUB_OUTPUT + + # test 돌릴때 레디스 필요 + - name: Start containers + run: docker-compose -f ./docker-compose-test.yaml up -d + + # Gradlew 실행 허용 + - name: Run chmod to make gradlew executable + run: chmod +x ./gradlew + + # Gradle 빌드 + - name: Build with Gradle + id: gradle + uses: gradle/gradle-build-action@v2 + with: + arguments: | + build + --scan + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + # NCP Container Registry 로그인 + - name: Login to NCP Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.NCP_CONTAINER_REGISTRY }} + username: ${{ secrets.NCP_ACCESS_KEY }} + password: ${{ secrets.NCP_SECRET_KEY }} + + # Docker 이미지 빌드 및 푸시 + - name: Docker Build and Push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ steps.image-tag.outputs.value }} + + # 서버로 docker-compose 파일 전송 + - name: Copy docker-compose.yml to NCP Server + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USERNAME }} + key: ${{ secrets.NCP_PRIVATE_KEY }} + port: ${{ secrets.NCP_PORT }} + source: docker-compose.yaml + target: /home/tenminute/ + + # 슬랙으로 빌드 스캔 결과 전송 + - name: Send to slack + uses: slackapi/slack-github-action@v1.24.0 + with: + payload: | + { + "text": "Gradle Build Scan Report of ${{ github.workflow }}: ${{ steps.gradle.outputs.build-scan-url }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + deploy: + runs-on: ubuntu-latest + environment: PROD + needs: build + steps: + - name: Deploy to NCP Server + uses: appleboy/ssh-action@master + env: + NCP_CONTAINER_REGISTRY: ${{ secrets.NCP_CONTAINER_REGISTRY }} + NCP_IMAGE_TAG: ${{ needs.build.outputs.image-tag }} + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USERNAME }} + key: ${{ secrets.NCP_PRIVATE_KEY }} + port: ${{ secrets.NCP_PORT }} + envs: NCP_CONTAINER_REGISTRY,NCP_IMAGE_TAG # docker-compose.yml 에서 사용할 환경 변수 + script: | + echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}" + docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ env.NCP_IMAGE_TAG }} + docker compose -f /home/tenminute/docker-compose.yaml up -d + docker image prune -a -f diff --git a/.github/workflows/production_deploy.yml b/.github/workflows/production_deploy.yml new file mode 100644 index 000000000..2a9c2234e --- /dev/null +++ b/.github/workflows/production_deploy.yml @@ -0,0 +1,30 @@ +name: production Deploy + +on: + workflow_dispatch: + inputs: + version: + description: 'version' + required: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: PROD + steps: + - name: Deploy to NCP Server + uses: appleboy/ssh-action@master + env: + NCP_CONTAINER_REGISTRY: ${{ secrets.NCP_CONTAINER_REGISTRY }} + NCP_IMAGE_TAG: ${{ github.event.inputs.version }} + with: + host: ${{ secrets.NCP_HOST }} + username: tenminute + key: ${{ secrets.NCP_PRIVATE_KEY }} + port: ${{ secrets.NCP_PORT }} + envs: NCP_CONTAINER_REGISTRY,NCP_IMAGE_TAG # docker-compose.yml 에서 사용할 환경 변수 + script: | + echo "${{ secrets.NCP_SECRET_KEY }}" | docker login -u "${{ secrets.NCP_ACCESS_KEY }}" --password-stdin "${{ secrets.NCP_CONTAINER_REGISTRY }}" + docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/server-spring:${{ github.event.inputs.version }} + docker compose -f /home/tenminute/docker-compose.yaml up -d + docker image prune -a -f \ No newline at end of file diff --git a/.github/workflows/project_set_pending.yml b/.github/workflows/project_set_pending.yml new file mode 100644 index 000000000..217fbf606 --- /dev/null +++ b/.github/workflows/project_set_pending.yml @@ -0,0 +1,98 @@ +name: Add New PR to project as pending status + +on: + pull_request: + types: + - opened + +jobs: + set_pending: + runs-on: ubuntu-latest + steps: + # Github App을 사용하여 토큰 생성 + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PEM }} + + # Github CLI를 사용하여 프로젝트 ID 및 필드 정보를 조회 후 project_data.json 파일에 저장 + - name: Get project data + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + ORGANIZATION: depromeet + PROJECT_NUMBER: 49 + run: | + gh api graphql -f query=' + query($org: String!, $number: Int!) { + organization(login: $org){ + projectV2(number: $number) { + id + fields(first:20) { + nodes { + ... on ProjectV2Field { + id + name + } + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + }' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json + + # json 파일에서 프로젝트 ID 및 필드 정보를 파싱하여 환경변수에 저장 + # 저장 필드 : 프로젝트 ID, Status 필드 ID, Status 필드의 Pending 옵션 ID + - name: Parse project data + run: | + echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV + echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV + echo 'PENDING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="처리 대기") |.id' project_data.json) >> $GITHUB_ENV + + # PR을 프로젝트에 추가 후 item_id를 환경변수에 저장 + - name: Add PR to project + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + PR_ID: ${{ github.event.pull_request.node_id }} + run: | + item_id="$( gh api graphql -f query=' + mutation($project:ID!, $pr:ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) { + item { + id + } + } + }' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')" + + echo 'ITEM_ID='$item_id >> $GITHUB_ENV + + # 프로젝트에 추가된 PR의 item_id를 환경변수에서 읽어와 Status 필드의 값을 Pending으로 변경 + - name: Set status to pending + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + run: | + PENDING_OPTION_ID="${{ env.PENDING_OPTION_ID }}" + + gh api graphql -f query=' + mutation ($project: ID!, $item: ID!, $status_field: ID!, $status_value: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $project, + itemId: $item, + fieldId: $status_field, + value: {singleSelectOptionId: $status_value} + } + ) { + projectV2Item { + id + } + } + }' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=$PENDING_OPTION_ID --silent diff --git a/.gitignore b/.gitignore index 9ab794bf4..d4f7ec682 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ out/ ### Custom ### *.env -*.DS_Stroe \ No newline at end of file +*.DS_Store +/src/main/generated diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b54d10e7a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-temurin:17 +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","/app.jar"] diff --git a/build.gradle b/build.gradle index 83f4baa8c..29f0c22d7 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,9 @@ plugins { id 'java' id 'org.springframework.boot' version '3.1.5' id 'io.spring.dependency-management' version '1.1.3' + id 'com.diffplug.spotless' version '6.11.0' + id 'jacoco' + id 'org.sonarqube' version '4.4.1.3373' } group = 'com.depromeet' @@ -24,11 +27,50 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.security:spring-security-oauth2-client' + implementation 'org.springframework.security:spring-security-oauth2-jose' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Apple Login + implementation 'org.bouncycastle:bcpkix-jdk18on:1.72' + + // Querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // AWS + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' } tasks.named('bootBuildImage') { @@ -37,4 +79,113 @@ tasks.named('bootBuildImage') { tasks.named('test') { useJUnitPlatform() + finalizedBy 'jacocoTestReport' +} + +def jacocoDir = layout.buildDirectory.dir("reports/") + +def QDomains = [] +for (qPattern in '*.QA'..'*.QZ') { // qPattern = '*.QA', '*.QB', ... '*.QZ' + QDomains.add(qPattern + '*') +} + +def jacocoExcludePatterns = [ + // 측정 안하고 싶은 패턴 + "**/*Application*", + "**/*Config*", + "**/*Exception*", + "**/*Request*", + "**/*Response*", + "**/*Dto*", + "**/*Interceptor*", + "**/*Filter*", + "**/*Resolver*", + "**/*Entity*", + "**/*Error*/**", + "**/test/**", + "**/resources/**", + "**/error/**", + "**/config/**" +] + +sonar { + properties { + property "sonar.projectKey", "depromeet_10mm-server" + property "sonar.organization", "depromeet-1" + property "sonar.host.url", "https://sonarcloud.io" + property 'sonar.sources', 'src' + property 'sonar.language', 'java' + property 'sonar.sourceEncoding', 'UTF-8' + property 'sonar.exclusions', jacocoExcludePatterns.join(',') + property 'sonar.test.inclusions', '**/*Test.java' + property 'sonar.java.coveragePlugin', 'jacoco' + property 'sonar.coverage.jacoco.xmlReportPaths', jacocoDir.get().file("jacoco/index.xml").asFile + } +} + +jacoco { + toolVersion = "0.8.8" +} + +jacocoTestReport { + dependsOn test + reports { + html.required.set(true) + xml.required.set(true) + csv.required.set(true) + html.destination jacocoDir.get().file("jacoco/index.html").asFile + xml.destination jacocoDir.get().file("jacoco/index.xml").asFile + csv.destination jacocoDir.get().file("jacoco/index.csv").asFile + } + + afterEvaluate { + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, excludes: jacocoExcludePatterns + QDomains) // Querydsl 관련 제거 + }) + ) + } + finalizedBy jacocoTestCoverageVerification +} + +jacocoTestCoverageVerification { + + violationRules { + rule { + // rule 활성화 + enabled = true + + // 클래스 단위로 룰 체크 + element = 'CLASS' + + // 라인 커버리지를 최소 80% 만족 +// limit { +// counter = 'LINE' +// value = 'COVEREDRATIO' +// minimum = 0.80 +// } + + excludes = jacocoExcludePatterns + QDomains + } + } +} + +jar.enabled = false + +spotless { + java { + target("**/*.java") + importOrder() + removeUnusedImports() // 사용하지 않는 import 제거 + trimTrailingWhitespace() // 공백 제거 + endWithNewline() // 끝부분 NewLine 처리 + googleJavaFormat().aosp() // google java format + } +} + +// pre-commit spotless check script +tasks.register('updateGitHooks', Copy) { + from './scripts/pre-commit' + into './.git/hooks' } +compileJava.dependsOn updateGitHooks diff --git a/docker-compose-test.yaml b/docker-compose-test.yaml new file mode 100644 index 000000000..404a772ba --- /dev/null +++ b/docker-compose-test.yaml @@ -0,0 +1,9 @@ +version: "3.8" + +services: + redis: + image: "redis:alpine" + ports: + - "6379:6379" + environment: + - TZ=Asia/Seoul diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..162620430 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,20 @@ +version: "3.8" + +services: + backend: + image: ${NCP_CONTAINER_REGISTRY}/server-spring:${NCP_IMAGE_TAG} + container_name: server-spring + restart: always + environment: + - TZ=Asia/Seoul + network_mode: host + env_file: + - .env + redis: + image: "redis:alpine" + container_name: redis + ports: + - "6379:6379" + environment: + - TZ=Asia/Seoul + network_mode: "host" diff --git a/lombok.config b/lombok.config new file mode 100644 index 000000000..7a21e8804 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100644 index 000000000..7b75d9c86 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,10 @@ +#!/bin/bash +echo "Running spotless check" +./gradlew spotlessCheck +if [ \$? -eq 0 ] +then + echo "Spotless check succeed" +else + echo "Spotless check failed" >&2 +exit 1 +fi diff --git a/settings.gradle b/settings.gradle index 29e2d2ad7..24b931473 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,13 @@ +plugins { + id "com.gradle.enterprise" version "3.15.1" +} + +gradleEnterprise { + buildScan { + publishAlwaysIf(System.getenv("CI") != null) + termsOfServiceUrl = "https://gradle.com/terms-of-service" + termsOfServiceAgree = "yes" + } +} + rootProject.name = 'tenminute' diff --git a/src/main/java/com/depromeet/TenMinuteApplication.java b/src/main/java/com/depromeet/TenminuteApplication.java similarity index 52% rename from src/main/java/com/depromeet/TenMinuteApplication.java rename to src/main/java/com/depromeet/TenminuteApplication.java index 50744aa4c..43c3a0ba1 100644 --- a/src/main/java/com/depromeet/TenMinuteApplication.java +++ b/src/main/java/com/depromeet/TenminuteApplication.java @@ -4,8 +4,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class TenMinuteApplication { - public static void main(String[] args) { - SpringApplication.run(TenMinuteApplication.class, args); - } -} \ No newline at end of file +public class TenminuteApplication { + public static void main(String[] args) { + SpringApplication.run(TenminuteApplication.class, args); + } +} diff --git a/src/main/java/com/depromeet/domain/auth/api/AuthController.java b/src/main/java/com/depromeet/domain/auth/api/AuthController.java new file mode 100644 index 000000000..bd3a10fcb --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/api/AuthController.java @@ -0,0 +1,61 @@ +package com.depromeet.domain.auth.api; + +import com.depromeet.domain.auth.application.AuthService; +import com.depromeet.domain.auth.dto.request.MemberRegisterRequest; +import com.depromeet.domain.auth.dto.request.UsernamePasswordRequest; +import com.depromeet.domain.auth.dto.response.TokenPairResponse; +import com.depromeet.global.util.CookieUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "1-1. [인증]", description = "인증 관련 API") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final CookieUtil cookieUtil; + + @Operation(summary = "회원가입", description = "회원가입을 진행합니다.") + @PostMapping("/register") + public ResponseEntity memberRegister(@Valid @RequestBody MemberRegisterRequest request) { + authService.registerMember(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "아이디/비밀번호 임시 회원가입", description = "아이디/비밀번호 임시 회원가입을 진행합니다.") + @PostMapping("/temp-register") + public ResponseEntity memberTempRegister( + @Valid @RequestBody UsernamePasswordRequest request) { + TokenPairResponse response = authService.registerWithUsernameAndPassword(request); + + String accessToken = response.accessToken(); + String refreshToken = response.refreshToken(); + HttpHeaders tokenHeaders = cookieUtil.generateTokenCookies(accessToken, refreshToken); + + return ResponseEntity.status(HttpStatus.CREATED).headers(tokenHeaders).body(response); + } + + @Operation(summary = "로그인", description = "토큰 발급을 위해 로그인을 진행합니다.") + @PostMapping("/login") + public ResponseEntity memberLogin( + @Valid @RequestBody UsernamePasswordRequest request) { + TokenPairResponse response = authService.loginMember(request); + + String accessToken = response.accessToken(); + String refreshToken = response.refreshToken(); + HttpHeaders tokenHeaders = cookieUtil.generateTokenCookies(accessToken, refreshToken); + + return ResponseEntity.ok().headers(tokenHeaders).body(response); + } +} diff --git a/src/main/java/com/depromeet/domain/auth/application/AuthService.java b/src/main/java/com/depromeet/domain/auth/application/AuthService.java new file mode 100644 index 000000000..2db4db031 --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/application/AuthService.java @@ -0,0 +1,93 @@ +package com.depromeet.domain.auth.application; + +import com.depromeet.domain.auth.dto.request.MemberRegisterRequest; +import com.depromeet.domain.auth.dto.request.UsernamePasswordRequest; +import com.depromeet.domain.auth.dto.response.TokenPairResponse; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.MemberRole; +import com.depromeet.domain.member.domain.MemberStatus; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.util.MemberUtil; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final MemberRepository memberRepository; + private final MemberUtil memberUtil; + private final PasswordEncoder passwordEncoder; + private final JwtTokenService jwtTokenService; + + public void registerMember(MemberRegisterRequest request) { + final Member member = memberUtil.getCurrentMember(); + member.register(request.nickname()); + } + + public TokenPairResponse registerWithUsernameAndPassword(UsernamePasswordRequest request) { + Optional member = memberRepository.findByUsername(request.username()); + + // 첫 회원가입 + if (member.isEmpty()) { + String encodedPassword = passwordEncoder.encode(request.password()); + final Member savedMember = + Member.createGuestMember(request.username(), encodedPassword); + memberRepository.save(savedMember); + return getLoginResponse(savedMember); + } + + // 토큰 만료된, 이미 임시 회원가입한 게스트 회원 + Member existMember = member.get(); + validatePasswordMatches(existMember, request.password()); + return getLoginResponse(existMember); + } + + public TokenPairResponse loginMember(UsernamePasswordRequest request) { + final Member member = + memberRepository + .findByUsername(request.username()) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + validateNotGuestMember(member); + validatePasswordMatches(member, request.password()); + validateNormalMember(member); + + member.updateLastLoginAt(); + + return getLoginResponse(member); + } + + private void validateNotGuestMember(Member member) { + if (member.getRole() == MemberRole.GUEST) { + throw new CustomException(ErrorCode.GUEST_MEMBER_REQUIRES_REGISTRATION); + } + } + + private void validateNormalMember(Member member) { + if (member.getStatus() != MemberStatus.NORMAL) { + throw new CustomException(ErrorCode.MEMBER_INVALID_NORMAL); + } + } + + private void validatePasswordMatches(Member member, String password) { + if (!passwordEncoder.matches(password, member.getPassword())) { + throw new CustomException(ErrorCode.PASSWORD_NOT_MATCHES); + } + } + + private TokenPairResponse getLoginResponse(Member member) { + String accessToken = jwtTokenService.createAccessToken(member.getId(), member.getRole()); + String refreshToken = jwtTokenService.createRefreshToken(member.getId()); + + return TokenPairResponse.from(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/depromeet/domain/auth/application/JwtTokenService.java b/src/main/java/com/depromeet/domain/auth/application/JwtTokenService.java new file mode 100644 index 000000000..7d0c03723 --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/application/JwtTokenService.java @@ -0,0 +1,87 @@ +package com.depromeet.domain.auth.application; + +import com.depromeet.domain.auth.dao.RefreshTokenRepository; +import com.depromeet.domain.auth.domain.RefreshToken; +import com.depromeet.domain.auth.dto.response.AccessToken; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.MemberRole; +import com.depromeet.global.security.JwtTokenProvider; +import com.depromeet.global.security.PrincipalDetails; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtTokenService { + + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + + public String createAccessToken(Long memberId, MemberRole memberRole) { + return jwtTokenProvider.generateAccessToken(memberId, memberRole); + } + + public String createRefreshToken(Long memberId) { + String token = jwtTokenProvider.generateRefreshToken(memberId); + RefreshToken refreshToken = + RefreshToken.builder() + .memberId(memberId) + .token(token) + .ttl(jwtTokenProvider.getRefreshTokenExpirationTime()) + .build(); + refreshTokenRepository.save(refreshToken); + return token; + } + + public String reissueAccessToken(String refreshToken) { + Member member = getMemberFrom(refreshToken); + return createAccessToken(member.getId(), member.getRole()); + } + + public String reissueAccessToken(AccessToken accessToken) { + return createAccessToken(accessToken.memberId(), accessToken.memberRole()); + } + + public String reissueRefreshToken(String refreshToken) { + Member member = getMemberFrom(refreshToken); + return createRefreshToken(member.getId()); + } + + public String reissueRefreshToken(AccessToken accessToken) { + return createRefreshToken(accessToken.memberId()); + } + + private Member getMemberFrom(String refreshToken) throws NoSuchElementException { + Long memberId = jwtTokenProvider.parseRefreshToken(refreshToken); + return memberRepository.findById(memberId).orElseThrow(); + } + + public Authentication getAuthentication(String accessToken) { + AccessToken parsedAccessToken = jwtTokenProvider.parseAccessToken(accessToken); + + UserDetails userDetails = + new PrincipalDetails( + parsedAccessToken.memberId(), parsedAccessToken.memberRole().name()); + + return new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + } + + public boolean isAccessTokenExpired(String accessToken) { + return jwtTokenProvider.isAccessTokenExpired(accessToken); + } + + public boolean isRefreshTokenExpired(String refreshToken) { + return jwtTokenProvider.isRefreshTokenExpired(refreshToken); + } + + public AccessToken parseAccessToken(String accessToken) { + return jwtTokenProvider.parseAccessToken(accessToken); + } +} diff --git a/src/main/java/com/depromeet/domain/auth/dao/RefreshTokenRepository.java b/src/main/java/com/depromeet/domain/auth/dao/RefreshTokenRepository.java new file mode 100644 index 000000000..ef2958e14 --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/dao/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.depromeet.domain.auth.dao; + +import com.depromeet.domain.auth.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository {} diff --git a/src/main/java/com/depromeet/domain/auth/domain/RefreshToken.java b/src/main/java/com/depromeet/domain/auth/domain/RefreshToken.java new file mode 100644 index 000000000..3e64e4278 --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/domain/RefreshToken.java @@ -0,0 +1,23 @@ +package com.depromeet.domain.auth.domain; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash(value = "refreshToken") +public class RefreshToken { + + @Id private Long memberId; + private String token; + @TimeToLive private long ttl; + + @Builder + public RefreshToken(Long memberId, String token, long ttl) { + this.memberId = memberId; + this.token = token; + this.ttl = ttl; + } +} diff --git a/src/main/java/com/depromeet/domain/auth/dto/request/MemberRegisterRequest.java b/src/main/java/com/depromeet/domain/auth/dto/request/MemberRegisterRequest.java new file mode 100644 index 000000000..f6ad545f3 --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/dto/request/MemberRegisterRequest.java @@ -0,0 +1,5 @@ +package com.depromeet.domain.auth.dto.request; + +public record MemberRegisterRequest(String nickname) { + // TODO: Add validation +} diff --git a/src/main/java/com/depromeet/domain/auth/dto/request/UsernameCheckRequest.java b/src/main/java/com/depromeet/domain/auth/dto/request/UsernameCheckRequest.java new file mode 100644 index 000000000..a9b17adde --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/dto/request/UsernameCheckRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record UsernameCheckRequest( + @NotNull(message = "아이디는 비워둘 수 없습니다.") + @Schema(description = "회원 아이디", defaultValue = "username") + String username) {} diff --git a/src/main/java/com/depromeet/domain/auth/dto/request/UsernamePasswordRequest.java b/src/main/java/com/depromeet/domain/auth/dto/request/UsernamePasswordRequest.java new file mode 100644 index 000000000..aa4e8328c --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/dto/request/UsernamePasswordRequest.java @@ -0,0 +1,12 @@ +package com.depromeet.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record UsernamePasswordRequest( + @NotNull(message = "아이디는 비워둘 수 없습니다.") + @Schema(description = "회원 아이디", defaultValue = "username") + String username, + @NotNull(message = "비밀번호는 비워둘 수 없습니다.") + @Schema(description = "회원 비밀번호", defaultValue = "password") + String password) {} diff --git a/src/main/java/com/depromeet/domain/auth/dto/response/AccessToken.java b/src/main/java/com/depromeet/domain/auth/dto/response/AccessToken.java new file mode 100644 index 000000000..83973341a --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/dto/response/AccessToken.java @@ -0,0 +1,5 @@ +package com.depromeet.domain.auth.dto.response; + +import com.depromeet.domain.member.domain.MemberRole; + +public record AccessToken(Long memberId, MemberRole memberRole) {} diff --git a/src/main/java/com/depromeet/domain/auth/dto/response/MemberTempRegisterResponse.java b/src/main/java/com/depromeet/domain/auth/dto/response/MemberTempRegisterResponse.java new file mode 100644 index 000000000..cc145dc94 --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/dto/response/MemberTempRegisterResponse.java @@ -0,0 +1,11 @@ +package com.depromeet.domain.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MemberTempRegisterResponse( + @Schema(description = "임시 회원가입 멤버 ID", defaultValue = "1") Long memberId) { + + public static MemberTempRegisterResponse from(Long memberId) { + return new MemberTempRegisterResponse(memberId); + } +} diff --git a/src/main/java/com/depromeet/domain/auth/dto/response/TokenPairResponse.java b/src/main/java/com/depromeet/domain/auth/dto/response/TokenPairResponse.java new file mode 100644 index 000000000..6240b1bfc --- /dev/null +++ b/src/main/java/com/depromeet/domain/auth/dto/response/TokenPairResponse.java @@ -0,0 +1,12 @@ +package com.depromeet.domain.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TokenPairResponse( + @Schema(description = "엑세스 토큰", defaultValue = "accessToken") String accessToken, + @Schema(description = "리프레시 토큰", defaultValue = "refreshToken") String refreshToken) { + + public static TokenPairResponse from(String accessToken, String refreshToken) { + return new TokenPairResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/depromeet/domain/common/model/BaseTimeEntity.java b/src/main/java/com/depromeet/domain/common/model/BaseTimeEntity.java new file mode 100644 index 000000000..04c19cbf6 --- /dev/null +++ b/src/main/java/com/depromeet/domain/common/model/BaseTimeEntity.java @@ -0,0 +1,22 @@ +package com.depromeet.domain.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @Column(updatable = false) + @CreatedDate + private LocalDateTime createdAt; + + @Column @LastModifiedDate private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/depromeet/domain/image/api/ImageController.java b/src/main/java/com/depromeet/domain/image/api/ImageController.java new file mode 100644 index 000000000..78b01c459 --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/api/ImageController.java @@ -0,0 +1,54 @@ +package com.depromeet.domain.image.api; + +import com.depromeet.domain.image.application.ImageService; +import com.depromeet.domain.image.dto.request.MemberProfileImageCreateRequest; +import com.depromeet.domain.image.dto.request.MemberProfileImageUploadCompleteRequest; +import com.depromeet.domain.image.dto.request.MissionRecordImageCreateRequest; +import com.depromeet.domain.image.dto.request.MissionRecordImageUploadCompleteRequest; +import com.depromeet.domain.image.dto.response.PresignedUrlResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "4. [이미지]", description = "이미지 관련 API입니다.") +@RestController +@RequiredArgsConstructor +public class ImageController { + private final ImageService imageService; + + @Operation( + summary = "미션 기록 이미지 Presigned URL 생성", + description = "미션 기록 이미지 Presigned URL를 생성합니다.") + @PostMapping("/records/upload-url") + public PresignedUrlResponse missionRecordPresignedUrlCreate( + @Valid @RequestBody MissionRecordImageCreateRequest request) { + return imageService.createMissionRecordPresignedUrl(request); + } + + @Operation(summary = "미션 기록 이미지 업로드 완료처리", description = "미션 기록 이미지 업로드 완료 시 호출하시면 됩니다.") + @PostMapping("/records/upload-complete") + public void missionRecordUploaded( + @Valid @RequestBody MissionRecordImageUploadCompleteRequest request) { + imageService.uploadCompleteMissionRecord(request); + } + + @Operation( + summary = "회원 프로필 이미지 Presigned URL 생성", + description = "회원 프로필 이미지 Presigned URL을 생성합니다.") + @PostMapping("/members/me/upload-url") + public PresignedUrlResponse memberProfilePresignedUrlCreate( + @Valid @RequestBody MemberProfileImageCreateRequest request) { + return imageService.createMemberProfilePresignedUrl(request); + } + + @Operation(summary = "회원 프로필 이미지 업로드 완료처리", description = "회원 프로필 이미지 업로드 완료 시 호출하시면 됩니다.") + @PostMapping("/members/me/upload-complete") + public void memberProfileUploaded( + @Valid @RequestBody MemberProfileImageUploadCompleteRequest request) { + imageService.uploadCompleteMemberProfile(request); + } +} diff --git a/src/main/java/com/depromeet/domain/image/application/ImageService.java b/src/main/java/com/depromeet/domain/image/application/ImageService.java new file mode 100644 index 000000000..122d8019c --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/application/ImageService.java @@ -0,0 +1,175 @@ +package com.depromeet.domain.image.application; + +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.Headers; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.depromeet.domain.image.domain.ImageFileExtension; +import com.depromeet.domain.image.domain.ImageType; +import com.depromeet.domain.image.dto.request.MemberProfileImageCreateRequest; +import com.depromeet.domain.image.dto.request.MemberProfileImageUploadCompleteRequest; +import com.depromeet.domain.image.dto.request.MissionRecordImageCreateRequest; +import com.depromeet.domain.image.dto.request.MissionRecordImageUploadCompleteRequest; +import com.depromeet.domain.image.dto.response.PresignedUrlResponse; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.dao.MissionRecordTtlRepository; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.util.MemberUtil; +import com.depromeet.global.util.SpringEnvironmentUtil; +import com.depromeet.infra.config.storage.StorageProperties; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ImageService { + private final MemberUtil memberUtil; + private final SpringEnvironmentUtil springEnvironmentUtil; + private final StorageProperties storageProperties; + private final AmazonS3 amazonS3; + private final MissionRecordRepository missionRecordRepository; + private final MissionRecordTtlRepository missionRecordTtlRepository; + + public PresignedUrlResponse createMissionRecordPresignedUrl( + MissionRecordImageCreateRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + + MissionRecord missionRecord = findMissionRecordById(request.missionRecordId()); + + Mission mission = missionRecord.getMission(); + validateMissionRecordUserMismatch(mission, currentMember); + + String fileName = + createFileName( + ImageType.MISSION_RECORD, + request.missionRecordId(), + request.imageFileExtension()); + GeneratePresignedUrlRequest generatePresignedUrlRequest = + createGeneratePreSignedUrlRequest( + storageProperties.bucket(), + fileName, + request.imageFileExtension().getUploadExtension()); + + String presignedUrl = amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); + + missionRecord.updateUploadStatusPending(); + missionRecordTtlRepository.deleteById(request.missionRecordId()); + return PresignedUrlResponse.from(presignedUrl); + } + + public void uploadCompleteMissionRecord(MissionRecordImageUploadCompleteRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + MissionRecord missionRecord = findMissionRecordById(request.missionRecordId()); + + Mission mission = missionRecord.getMission(); + validateMissionRecordUserMismatch(mission, currentMember); + + String imageUrl = + createImageUrl( + ImageType.MISSION_RECORD, + request.missionRecordId(), + request.imageFileExtension()); + missionRecord.updateUploadStatusComplete(request.remark(), imageUrl); + } + + public PresignedUrlResponse createMemberProfilePresignedUrl( + MemberProfileImageCreateRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + + String fileName = + createFileName( + ImageType.MEMBER_PROFILE, + currentMember.getId(), + request.imageFileExtension()); + GeneratePresignedUrlRequest generatePresignedUrlRequest = + createGeneratePreSignedUrlRequest( + storageProperties.bucket(), + fileName, + request.imageFileExtension().getUploadExtension()); + + String presignedUrl = amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); + return PresignedUrlResponse.from(presignedUrl); + } + + public void uploadCompleteMemberProfile(MemberProfileImageUploadCompleteRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + String imageUrl = null; + if (request.imageFileExtension() != null) { + imageUrl = + createImageUrl( + ImageType.MEMBER_PROFILE, + currentMember.getId(), + request.imageFileExtension()); + } + currentMember.updateProfile(Profile.createProfile(request.nickname(), imageUrl)); + } + + private MissionRecord findMissionRecordById(Long request) { + return missionRecordRepository + .findById(request) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND)); + } + + private String createFileName( + ImageType imageType, Long targetId, ImageFileExtension imageFileExtension) { + return springEnvironmentUtil.getCurrentProfile() + + "/" + + imageType.getValue() + + "/" + + targetId + + "/image." + + imageFileExtension.getUploadExtension(); + } + + private String createImageUrl( + ImageType imageType, Long targetId, ImageFileExtension imageFileExtension) { + return storageProperties.endpoint() + + "/" + + storageProperties.bucket() + + "/" + + springEnvironmentUtil.getCurrentProfile() + + "/" + + imageType.getValue() + + "/" + + targetId + + "/image." + + imageFileExtension.getUploadExtension(); + } + + private GeneratePresignedUrlRequest createGeneratePreSignedUrlRequest( + String bucket, String fileName, String fileExtension) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT) + .withKey(fileName) + .withContentType("image/" + fileExtension) + .withExpiration(getPreSignedUrlExpiration()); + + generatePresignedUrlRequest.addRequestParameter( + Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString()); + + return generatePresignedUrlRequest; + } + + private Date getPreSignedUrlExpiration() { + Date expiration = new Date(); + var expTimeMillis = expiration.getTime(); + expTimeMillis += 1000 * 60 * 30; + expiration.setTime(expTimeMillis); + return expiration; + } + + private void validateMissionRecordUserMismatch(Mission mission, Member member) { + if (!mission.getMember().getId().equals(member.getId())) { + throw new CustomException(ErrorCode.MISSION_RECORD_USER_MISMATCH); + } + } +} diff --git a/src/main/java/com/depromeet/domain/image/domain/ImageFileExtension.java b/src/main/java/com/depromeet/domain/image/domain/ImageFileExtension.java new file mode 100644 index 000000000..80e62a007 --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/domain/ImageFileExtension.java @@ -0,0 +1,15 @@ +package com.depromeet.domain.image.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageFileExtension { + JPEG("jpeg"), + JPG("jpg"), + PNG("png"), + ; + + private final String uploadExtension; +} diff --git a/src/main/java/com/depromeet/domain/image/domain/ImageType.java b/src/main/java/com/depromeet/domain/image/domain/ImageType.java new file mode 100644 index 000000000..573612e66 --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/domain/ImageType.java @@ -0,0 +1,14 @@ +package com.depromeet.domain.image.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageType { + MISSION_RECORD("mission_record"), + MEMBER_PROFILE("member_profile"), + MEMBER_BACKGROUND("member_background"), + ; + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/image/dto/request/MemberProfileImageCreateRequest.java b/src/main/java/com/depromeet/domain/image/dto/request/MemberProfileImageCreateRequest.java new file mode 100644 index 000000000..23990f88e --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/dto/request/MemberProfileImageCreateRequest.java @@ -0,0 +1,10 @@ +package com.depromeet.domain.image.dto.request; + +import com.depromeet.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MemberProfileImageCreateRequest( + @NotNull(message = "이미지 파일의 확장자는 비워둘 수 없습니다.") + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension) {} diff --git a/src/main/java/com/depromeet/domain/image/dto/request/MemberProfileImageUploadCompleteRequest.java b/src/main/java/com/depromeet/domain/image/dto/request/MemberProfileImageUploadCompleteRequest.java new file mode 100644 index 000000000..571649213 --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/dto/request/MemberProfileImageUploadCompleteRequest.java @@ -0,0 +1,11 @@ +package com.depromeet.domain.image.dto.request; + +import com.depromeet.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MemberProfileImageUploadCompleteRequest( + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension, + @NotNull(message = "닉네임은 비워둘 수 없습니다.") @Schema(description = "닉네임", defaultValue = "당근조이") + String nickname) {} diff --git a/src/main/java/com/depromeet/domain/image/dto/request/MissionRecordImageCreateRequest.java b/src/main/java/com/depromeet/domain/image/dto/request/MissionRecordImageCreateRequest.java new file mode 100644 index 000000000..d6e95680b --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/dto/request/MissionRecordImageCreateRequest.java @@ -0,0 +1,13 @@ +package com.depromeet.domain.image.dto.request; + +import com.depromeet.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MissionRecordImageCreateRequest( + @NotNull(message = "미션 기록 ID는 비워둘 수 없습니다.") + @Schema(description = "미션 기록 ID", defaultValue = "1") + Long missionRecordId, + @NotNull(message = "이미지 파일의 확장자는 비워둘 수 없습니다.") + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension) {} diff --git a/src/main/java/com/depromeet/domain/image/dto/request/MissionRecordImageUploadCompleteRequest.java b/src/main/java/com/depromeet/domain/image/dto/request/MissionRecordImageUploadCompleteRequest.java new file mode 100644 index 000000000..e3693e2fd --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/dto/request/MissionRecordImageUploadCompleteRequest.java @@ -0,0 +1,17 @@ +package com.depromeet.domain.image.dto.request; + +import com.depromeet.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record MissionRecordImageUploadCompleteRequest( + @NotNull(message = "미션 기록 ID는 비워둘 수 없습니다.") + @Schema(description = "미션 기록 ID", defaultValue = "1") + Long missionRecordId, + @NotNull(message = "이미지 파일의 확장자는 비워둘 수 없습니다.") + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension, + @Size(max = 200, message = "미션 일지는 20자 이하까지만 입력 가능합니다.") + @Schema(description = "미션 일지", defaultValue = "10분을 알차게 써서 뿌듯하다!") + String remark) {} diff --git a/src/main/java/com/depromeet/domain/image/dto/response/PresignedUrlResponse.java b/src/main/java/com/depromeet/domain/image/dto/response/PresignedUrlResponse.java new file mode 100644 index 000000000..ad2847281 --- /dev/null +++ b/src/main/java/com/depromeet/domain/image/dto/response/PresignedUrlResponse.java @@ -0,0 +1,9 @@ +package com.depromeet.domain.image.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PresignedUrlResponse(@Schema(description = "Presigned URL") String presignedUrl) { + public static PresignedUrlResponse from(String presignedUrl) { + return new PresignedUrlResponse(presignedUrl); + } +} diff --git a/src/main/java/com/depromeet/domain/member/api/MemberController.java b/src/main/java/com/depromeet/domain/member/api/MemberController.java new file mode 100644 index 000000000..5f4fa5131 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/api/MemberController.java @@ -0,0 +1,51 @@ +package com.depromeet.domain.member.api; + +import com.depromeet.domain.auth.dto.request.UsernameCheckRequest; +import com.depromeet.domain.member.application.MemberService; +import com.depromeet.domain.member.dto.request.NicknameCheckRequest; +import com.depromeet.domain.member.dto.response.MemberFindOneResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "1-2. [회원]", description = "회원 관련 API") +@RestController +@RequestMapping("/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @Operation(summary = "회원 정보 확인", description = "로그인 된 회원의 정보를 확인합니다.") + @GetMapping("/me") + public MemberFindOneResponse memberInfo() { + return memberService.findMemberInfo(); + } + + @Operation(summary = "아이디 중복 체크", description = "아이디 중복 체크를 진행합니다.") + @PostMapping("/check-username") + public ResponseEntity memberUsernameCheck( + @Valid @RequestBody UsernameCheckRequest request) { + memberService.checkUsername(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "닉네임 중복 체크", description = "닉네임 중복 체크를 진행합니다.") + @PostMapping("/check-nickname") + public ResponseEntity memberNicknameCheck( + @Valid @RequestBody NicknameCheckRequest request) { + memberService.checkNickname(request); + return ResponseEntity.ok().build(); + } + + // TODO: 테스트 코드 작성 필요 + @Operation(summary = "회원 탈퇴", description = "회원탈퇴를 진행합니다.") + @DeleteMapping("/withdrawal") + public ResponseEntity memberWithdrawal(@Valid @RequestBody UsernameCheckRequest request) { + memberService.withdrawal(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/depromeet/domain/member/application/MemberService.java b/src/main/java/com/depromeet/domain/member/application/MemberService.java new file mode 100644 index 000000000..2eafeea80 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/application/MemberService.java @@ -0,0 +1,54 @@ +package com.depromeet.domain.member.application; + +import com.depromeet.domain.auth.dao.RefreshTokenRepository; +import com.depromeet.domain.auth.dto.request.UsernameCheckRequest; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.dto.request.NicknameCheckRequest; +import com.depromeet.domain.member.dto.response.MemberFindOneResponse; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.util.MemberUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final MemberUtil memberUtil; + + @Transactional(readOnly = true) + public MemberFindOneResponse findMemberInfo() { + final Member currentMember = memberUtil.getCurrentMember(); + return MemberFindOneResponse.from(currentMember); + } + + @Transactional(readOnly = true) + public void checkUsername(UsernameCheckRequest request) { + if (memberRepository.existsByUsername(request.username())) { + throw new CustomException(ErrorCode.MEMBER_ALREADY_REGISTERED); + } + } + + @Transactional(readOnly = true) + public void checkNickname(NicknameCheckRequest request) { + if (memberRepository.existsByProfileNickname(request.nickname())) { + throw new CustomException(ErrorCode.MEMBER_ALREADY_NICKNAME); + } + } + + public void withdrawal(UsernameCheckRequest request) { + final Member member = + memberRepository + .findByUsername(request.username()) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + refreshTokenRepository.deleteById(member.getId()); + member.withdrawal(); + } +} diff --git a/src/main/java/com/depromeet/domain/member/dao/MemberRepository.java b/src/main/java/com/depromeet/domain/member/dao/MemberRepository.java new file mode 100644 index 000000000..10ce96aae --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/dao/MemberRepository.java @@ -0,0 +1,17 @@ +package com.depromeet.domain.member.dao; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.OauthInfo; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + Optional findByOauthInfo(OauthInfo oauthInfo); + + boolean existsByUsername(String username); + + boolean existsByProfileNickname(String nickname); + + Optional findByUsername(String username); +} diff --git a/src/main/java/com/depromeet/domain/member/domain/Member.java b/src/main/java/com/depromeet/domain/member/domain/Member.java new file mode 100644 index 000000000..08ce5b9ee --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/Member.java @@ -0,0 +1,134 @@ +package com.depromeet.domain.member.domain; + +import com.depromeet.domain.common.model.BaseTimeEntity; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Embedded private Profile profile = Profile.createProfile("", ""); + + @Embedded private OauthInfo oauthInfo; + + @Enumerated(EnumType.STRING) + private MemberStatus status; + + @Enumerated(EnumType.STRING) + private MemberRole role; + + @Enumerated(EnumType.STRING) + private MemberVisibility visibility; + + private LocalDateTime lastLoginAt; + + private String username; + + private String password; + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + private List missions = new ArrayList<>(); + + @Builder(access = AccessLevel.PRIVATE) + private Member( + Profile profile, + OauthInfo oauthInfo, + MemberStatus status, + MemberRole role, + MemberVisibility visibility, + LocalDateTime lastLoginAt, + String username, + String password) { + this.profile = profile; + this.oauthInfo = oauthInfo; + this.status = status; + this.role = role; + this.visibility = visibility; + this.lastLoginAt = lastLoginAt; + this.username = username; + this.password = password; + } + + public static Member createGuestMember(OauthInfo oauthInfo) { + return Member.builder() + .oauthInfo(oauthInfo) + .status(MemberStatus.NORMAL) + .role(MemberRole.GUEST) + .visibility(MemberVisibility.PUBLIC) + .build(); + } + + public static Member createGuestMember(String username, String password) { + return Member.builder() + .username(username) + .password(password) + .status(MemberStatus.NORMAL) + .role(MemberRole.GUEST) + .visibility(MemberVisibility.PUBLIC) + .build(); + } + + public static Member createNormalMember(Profile profile) { + return Member.builder() + .profile(profile) + .status(MemberStatus.NORMAL) + .role(MemberRole.USER) + .visibility(MemberVisibility.PUBLIC) + .lastLoginAt(LocalDateTime.now()) + .build(); + } + + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void register(String nickname) { + validateRegisterAvailable(); + // TODO: Profile 클래스를 제거하고 Member 클래스 필드로 변경 + // TODO: profileImageUrl이 항상 null이 되는 문제 해결 + this.profile = Profile.createProfile(nickname, null); + this.role = MemberRole.USER; + } + + public void updateProfile(Profile profile) { + this.profile = profile; + } + + public void withdrawal() { + if (this.status == MemberStatus.DELETED) { + throw new CustomException(ErrorCode.MEMBER_ALREADY_DELETED); + } + this.status = MemberStatus.DELETED; + } + + private void validateRegisterAvailable() { + if (role != MemberRole.GUEST) { + throw new CustomException(ErrorCode.MEMBER_ALREADY_REGISTERED); + } + } +} diff --git a/src/main/java/com/depromeet/domain/member/domain/MemberRole.java b/src/main/java/com/depromeet/domain/member/domain/MemberRole.java new file mode 100644 index 000000000..30ee70087 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/MemberRole.java @@ -0,0 +1,14 @@ +package com.depromeet.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberRole { + GUEST("ROLE_GUEST"), + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/member/domain/MemberStatus.java b/src/main/java/com/depromeet/domain/member/domain/MemberStatus.java new file mode 100644 index 000000000..c0e148f31 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/MemberStatus.java @@ -0,0 +1,14 @@ +package com.depromeet.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberStatus { + NORMAL("NORMAL"), + DELETED("DELETED"), + FORBIDDEN("FORBIDDEN"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/member/domain/MemberVisibility.java b/src/main/java/com/depromeet/domain/member/domain/MemberVisibility.java new file mode 100644 index 000000000..f9e48cbe6 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/MemberVisibility.java @@ -0,0 +1,13 @@ +package com.depromeet.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberVisibility { + PUBLIC("PUBLIC"), + PRIVATE("PRIVATE"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/member/domain/OauthInfo.java b/src/main/java/com/depromeet/domain/member/domain/OauthInfo.java new file mode 100644 index 000000000..6ab1ab0af --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/OauthInfo.java @@ -0,0 +1,23 @@ +package com.depromeet.domain.member.domain; + +import jakarta.persistence.Embeddable; +import lombok.*; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OauthInfo { + + private String oauthId; + private String oauthProvider; + + @Builder(access = AccessLevel.PRIVATE) + private OauthInfo(String oauthId, String oauthProvider) { + this.oauthId = oauthId; + this.oauthProvider = oauthProvider; + } + + public static OauthInfo createOauthInfo(String oauthId, String oauthProvider) { + return OauthInfo.builder().oauthId(oauthId).oauthProvider(oauthProvider).build(); + } +} diff --git a/src/main/java/com/depromeet/domain/member/domain/Profile.java b/src/main/java/com/depromeet/domain/member/domain/Profile.java new file mode 100644 index 000000000..851abc3cc --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/domain/Profile.java @@ -0,0 +1,22 @@ +package com.depromeet.domain.member.domain; + +import jakarta.persistence.Embeddable; +import lombok.*; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Profile { + private String nickname; + private String profileImageUrl; + + @Builder(access = AccessLevel.PRIVATE) + private Profile(String nickname, String profileImageUrl) { + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } + + public static Profile createProfile(String nickname, String profileImageUrl) { + return Profile.builder().nickname(nickname).profileImageUrl(profileImageUrl).build(); + } +} diff --git a/src/main/java/com/depromeet/domain/member/dto/request/NicknameCheckRequest.java b/src/main/java/com/depromeet/domain/member/dto/request/NicknameCheckRequest.java new file mode 100644 index 000000000..69384d2b9 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/dto/request/NicknameCheckRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.domain.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record NicknameCheckRequest( + @NotNull(message = "닉네임은 비워둘 수 없습니다.") + @Schema(description = "회원 닉네임", defaultValue = "nickname") + String nickname) {} diff --git a/src/main/java/com/depromeet/domain/member/dto/response/MemberFindOneResponse.java b/src/main/java/com/depromeet/domain/member/dto/response/MemberFindOneResponse.java new file mode 100644 index 000000000..780bac7b4 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/dto/response/MemberFindOneResponse.java @@ -0,0 +1,31 @@ +package com.depromeet.domain.member.dto.response; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.MemberRole; +import com.depromeet.domain.member.domain.MemberStatus; +import com.depromeet.domain.member.domain.MemberVisibility; +import java.time.LocalDateTime; + +public record MemberFindOneResponse( + Long memberId, + String nickname, + String profileImageUrl, + MemberStatus memberStatus, + MemberRole memberRole, + MemberVisibility memberVisibility, + String username, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + public static MemberFindOneResponse from(Member member) { + return new MemberFindOneResponse( + member.getId(), + member.getProfile().getNickname(), + member.getProfile().getProfileImageUrl(), + member.getStatus(), + member.getRole(), + member.getVisibility(), + member.getUsername(), + member.getCreatedAt(), + member.getUpdatedAt()); + } +} diff --git a/src/main/java/com/depromeet/domain/member/exception/.gitkeep b/src/main/java/com/depromeet/domain/member/exception/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/main/java/com/depromeet/domain/mission/api/MissionController.java b/src/main/java/com/depromeet/domain/mission/api/MissionController.java new file mode 100644 index 000000000..d0ec9d4b0 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/api/MissionController.java @@ -0,0 +1,67 @@ +package com.depromeet.domain.mission.api; + +import com.depromeet.domain.mission.application.MissionService; +import com.depromeet.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.domain.mission.dto.response.MissionFindAllResponse; +import com.depromeet.domain.mission.dto.response.MissionFindResponse; +import com.depromeet.domain.mission.dto.response.MissionUpdateResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RestController; + +@Tag(name = "2. [미션]", description = "미션 관련 API입니다.") +@RestController +@RequestMapping("/missions") +@RequiredArgsConstructor +public class MissionController { + + private final MissionService missionService; + + @Operation(summary = "미션 생성", description = "미션을 생성합니다.") + @PostMapping + public ResponseEntity missionCreate( + @Valid @RequestBody MissionCreateRequest missionCreateRequest) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(missionService.createMission(missionCreateRequest)); + } + + @Operation(summary = "미션 단건 조회", description = "미션을 한 개를 조회합니다.") + @GetMapping("/{missionId}") + public MissionFindResponse missionFindOne(@PathVariable Long missionId) { + return missionService.findOneMission(missionId); + } + + @Operation(summary = "미션 리스트 조회", description = "미션 리스트를 조회합니다.") + @GetMapping + public List missionFindAll() { + return missionService.findAllMission(); + } + + @Operation(summary = "미션 단건 수정", description = "단건 미션을 수정합니다.") + @PutMapping("/{missionId}") + public MissionUpdateResponse missionUpdate( + @Valid @RequestBody MissionUpdateRequest missionUpdateRequest, + @PathVariable Long missionId) { + return missionService.updateMission(missionUpdateRequest, missionId); + } + + @Operation(summary = "미션 단건 삭제", description = "단건 미션을 삭제합니다.") + @DeleteMapping("/{missionId}") + public void missionDelete(@PathVariable Long missionId) { + missionService.deleteMission(missionId); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/application/MissionService.java b/src/main/java/com/depromeet/domain/mission/application/MissionService.java new file mode 100644 index 000000000..021342310 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/application/MissionService.java @@ -0,0 +1,135 @@ +package com.depromeet.domain.mission.application; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.domain.mission.dto.response.*; +import com.depromeet.domain.missionRecord.dao.MissionRecordTtlRepository; +import com.depromeet.domain.missionRecord.domain.ImageUploadStatus; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.domain.missionRecord.domain.MissionRecordTtl; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.util.MemberUtil; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MissionService { + + private final MissionRepository missionRepository; + private final MissionRecordTtlRepository missionRecordTtlRepository; + private final MemberUtil memberUtil; + + public MissionCreateResponse createMission(MissionCreateRequest missionCreateRequest) { + Mission mission = createMissionEntity(missionCreateRequest); + Mission saveMission = missionRepository.save(mission); + return MissionCreateResponse.from(saveMission); + } + + @Transactional(readOnly = true) // 읽기 전용 트랜잭션 설정. 읽기 전용으로 설정한다. + public MissionFindResponse findOneMission(Long missionId) { + Mission mission = + missionRepository + .findById(missionId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND)); + return MissionFindResponse.from(mission); + } + + @Transactional(readOnly = true) // 읽기 전용 트랜잭션 설정. 읽기 전용으로 설정한다. + public List findAllMission() { + Member currentMember = memberUtil.getCurrentMember(); + final LocalDate today = LocalDate.now(); + + List missions = missionRepository.findMissionsWithRecords(currentMember.getId()); + + List results = new ArrayList<>(); + for (Mission mission : missions) { + List records = mission.getMissionRecords(); + + Optional optionalRecord = + records.stream() + .filter(record -> record.getStartedAt().toLocalDate().equals(today)) + .findFirst(); + + // 당일 수행한 미션기록이 없으면 NONE + if (optionalRecord.isEmpty()) { + results.add(MissionFindAllResponse.of(mission, MissionStatus.NONE, null, null)); + continue; + } + + // 당일 수행한 미션기록의 인증사진이 존재하면 COMPLETE + if (optionalRecord.get().getUploadStatus() == ImageUploadStatus.COMPLETE) { + results.add( + MissionFindAllResponse.of(mission, MissionStatus.COMPLETED, null, null)); + continue; + } + + // 레디스에 미션기록의 인증사진 인증 대기시간 값이 존재하면 REQUIRED + Optional missionRecordTTL = + missionRecordTtlRepository.findById(optionalRecord.get().getId()); + + if (missionRecordTTL.isPresent()) { + results.add( + MissionFindAllResponse.of( + mission, + MissionStatus.REQUIRED, + missionRecordTTL.get().getTtlFinishedAt(), + optionalRecord.get().getId())); + continue; + } + + throw new CustomException(ErrorCode.MISSION_STATUS_MISMATCH); + } + + return results; + } + + public MissionUpdateResponse updateMission( + MissionUpdateRequest missionUpdateRequest, Long missionId) { + Mission mission = + missionRepository + .findById(missionId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND)); + mission.updateMission( + missionUpdateRequest.name(), + missionUpdateRequest.content(), + missionUpdateRequest.visibility()); + return MissionUpdateResponse.from(mission); + } + + public void deleteMission(Long missionId) { + missionRepository.deleteById(missionId); + } + + private Integer maxSort(Member member) { + Mission missionByMaxSort = missionRepository.findTopByMemberOrderBySortDesc(member); + return missionByMaxSort == null ? 1 : missionByMaxSort.getSort() + 1; + } + + private Mission createMissionEntity(MissionCreateRequest missionCreateRequest) { + LocalDateTime startedAt = LocalDateTime.now(); + final Member member = memberUtil.getCurrentMember(); + Integer maxSort = maxSort(member); + + return Mission.createMission( + missionCreateRequest.name(), + missionCreateRequest.content(), + maxSort, + missionCreateRequest.category(), + missionCreateRequest.visibility(), + startedAt, + startedAt.plusWeeks(2), + member); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/dao/MissionRepository.java b/src/main/java/com/depromeet/domain/mission/dao/MissionRepository.java new file mode 100644 index 000000000..681c51337 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepository.java @@ -0,0 +1,9 @@ +package com.depromeet.domain.mission.dao; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.domain.Mission; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MissionRepository extends JpaRepository, MissionRepositoryCustom { + Mission findTopByMemberOrderBySortDesc(Member member); +} diff --git a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java new file mode 100644 index 000000000..e672eff87 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.depromeet.domain.mission.dao; + +import com.depromeet.domain.mission.domain.Mission; +import java.util.List; + +public interface MissionRepositoryCustom { + + List findMissionsWithRecords(Long memberId); +} diff --git a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java new file mode 100644 index 000000000..18ea462f6 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.depromeet.domain.mission.dao; + +import static com.depromeet.domain.mission.domain.QMission.*; +import static com.depromeet.domain.missionRecord.domain.QMissionRecord.*; + +import com.depromeet.domain.mission.domain.Mission; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MissionRepositoryImpl implements MissionRepositoryCustom { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findMissionsWithRecords(Long memberId) { + JPAQuery query = + jpaQueryFactory + .selectFrom(mission) + .leftJoin(mission.missionRecords, missionRecord) + .where(memberIdEq(memberId)) + .orderBy(mission.id.desc()) + .fetchJoin(); + return query.fetch(); + } + + private BooleanExpression memberIdEq(Long memberId) { + return memberId == null ? null : mission.member.id.eq(memberId); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/domain/ArchiveStatus.java b/src/main/java/com/depromeet/domain/mission/domain/ArchiveStatus.java new file mode 100644 index 000000000..8a62a73aa --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/domain/ArchiveStatus.java @@ -0,0 +1,13 @@ +package com.depromeet.domain.mission.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ArchiveStatus { + NONE("미완료"), + ARCHIVED("완료"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/mission/domain/Mission.java b/src/main/java/com/depromeet/domain/mission/domain/Mission.java new file mode 100644 index 000000000..bc27a525e --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/domain/Mission.java @@ -0,0 +1,118 @@ +package com.depromeet.domain.mission.domain; + +import com.depromeet.domain.common.model.BaseTimeEntity; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mission extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "mission_id") + private Long id; + + @Comment("미션 이름") + @Column(nullable = false, length = 20) + private String name; + + @Comment("미션 내용") + @Column(length = 30) + private String content; + + @Comment("미션 정렬값") + @Column(nullable = false) + private Integer sort; + + @Enumerated(EnumType.STRING) + private ArchiveStatus archiveStatus; + + @Enumerated(EnumType.STRING) + private MissionCategory category; + + @Enumerated(EnumType.STRING) + private MissionVisibility visibility; + + private LocalDateTime startedAt; + + private LocalDateTime finishedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @OneToMany(mappedBy = "mission", cascade = CascadeType.ALL, orphanRemoval = true) + private List missionRecords = new ArrayList<>(); + + @Builder(access = AccessLevel.PRIVATE) + private Mission( + String name, + String content, + Integer sort, + ArchiveStatus archiveStatus, + MissionCategory category, + MissionVisibility visibility, + LocalDateTime startedAt, + LocalDateTime finishedAt, + Member member) { + this.name = name; + this.content = content; + this.sort = sort; + this.archiveStatus = archiveStatus; + this.category = category; + this.visibility = visibility; + this.startedAt = startedAt; + this.finishedAt = finishedAt; + this.member = member; + } + + public static Mission createMission( + String name, + String content, + Integer sort, + MissionCategory category, + MissionVisibility visibility, + LocalDateTime startedAt, + LocalDateTime finishedAt, + Member member) { + return Mission.builder() + .name(name) + .content(content) + .sort(sort) + .archiveStatus(ArchiveStatus.NONE) + .category(category) + .visibility(visibility) + .startedAt(startedAt) + .finishedAt(finishedAt) + .member(member) + .build(); + } + + public void updateMission(String name, String content, MissionVisibility visibility) { + this.name = name; + this.content = content; + this.visibility = visibility; + } +} diff --git a/src/main/java/com/depromeet/domain/mission/domain/MissionCategory.java b/src/main/java/com/depromeet/domain/mission/domain/MissionCategory.java new file mode 100644 index 000000000..069722a3a --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/domain/MissionCategory.java @@ -0,0 +1,18 @@ +package com.depromeet.domain.mission.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MissionCategory { + EXERCISE("운동"), + STUDY("공부"), + READING("글 읽기"), + WRITING("글 쓰기"), + PROJECT("프로젝트 / 작업"), + WATCHING("영상 보기 / 팟캐스트 듣기"), + ETC("기타"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/mission/domain/MissionVisibility.java b/src/main/java/com/depromeet/domain/mission/domain/MissionVisibility.java new file mode 100644 index 000000000..a4f325450 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/domain/MissionVisibility.java @@ -0,0 +1,14 @@ +package com.depromeet.domain.mission.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MissionVisibility { + ALL("전체 공개"), + FOLLOWER("팔로워에게 공개"), + NONE("비공개"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/mission/dto/request/MissionCreateRequest.java b/src/main/java/com/depromeet/domain/mission/dto/request/MissionCreateRequest.java new file mode 100644 index 000000000..915ee9ca1 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/request/MissionCreateRequest.java @@ -0,0 +1,20 @@ +package com.depromeet.domain.mission.dto.request; + +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record MissionCreateRequest( + @NotBlank(message = "이름은 비워둘 수 없습니다.") + @Size(min = 1, max = 20, message = "미션명은 1자 이상 20자 이하") + @Schema(description = "미션 이름", defaultValue = "default name") + String name, + @Size(min = 1, max = 30, message = "미션 내용은 1자 이상 30자 이하") + @Schema(description = "미션 내용", defaultValue = "default content") + String content, + @NotNull @Schema(description = "미션 카테고리", defaultValue = "STUDY") MissionCategory category, + @NotNull @Schema(description = "미션 공개여부", defaultValue = "ALL") + MissionVisibility visibility) {} diff --git a/src/main/java/com/depromeet/domain/mission/dto/request/MissionUpdateRequest.java b/src/main/java/com/depromeet/domain/mission/dto/request/MissionUpdateRequest.java new file mode 100644 index 000000000..c7b5a2827 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/request/MissionUpdateRequest.java @@ -0,0 +1,19 @@ +package com.depromeet.domain.mission.dto.request; + +import com.depromeet.domain.mission.domain.MissionVisibility; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record MissionUpdateRequest( + @NotBlank(message = "이름은 비워둘 수 없습니다.") + @Size(min = 1, max = 20, message = "미션명은 1자 이상 20자 이하") + @Schema(description = "미션 이름", defaultValue = "default name") + String name, + @Size(min = 1, max = 30, message = "미션 내용은 1자 이상 30자 이하") + @Schema(description = "미션 내용", defaultValue = "default content") + String content, + @NotNull(message = "미션 공개 여부가 null일 수 없습니다.") + @Schema(description = "미션 공개여부", defaultValue = "ALL") + MissionVisibility visibility) {} diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/MissionCreateResponse.java b/src/main/java/com/depromeet/domain/mission/dto/response/MissionCreateResponse.java new file mode 100644 index 000000000..76e1b03bd --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/MissionCreateResponse.java @@ -0,0 +1,23 @@ +package com.depromeet.domain.mission.dto.response; + +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MissionCreateResponse( + @Schema(description = "미션 ID", defaultValue = "1") Long missionId, + @Schema(description = "미션 이름", defaultValue = "default name") String name, + @Schema(description = "미션 내용", defaultValue = "default content") String content, + @Schema(description = "미션 카테고리", defaultValue = "STUDY") MissionCategory category, + @Schema(description = "미션 공개여부", defaultValue = "ALL") MissionVisibility visibility) { + + public static MissionCreateResponse from(Mission mission) { + return new MissionCreateResponse( + mission.getId(), + mission.getName(), + mission.getContent(), + mission.getCategory(), + mission.getVisibility()); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/MissionFindAllResponse.java b/src/main/java/com/depromeet/domain/mission/dto/response/MissionFindAllResponse.java new file mode 100644 index 000000000..7f1b3c44f --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/MissionFindAllResponse.java @@ -0,0 +1,39 @@ +package com.depromeet.domain.mission.dto.response; + +import com.depromeet.domain.mission.domain.ArchiveStatus; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record MissionFindAllResponse( + @Schema(description = "미션 ID", defaultValue = "1") Long missionId, + @Schema(description = "미션 이름", defaultValue = "default name") String name, + @Schema(description = "미션 내용", defaultValue = "default content") String content, + @Schema(description = "미션 카테고리", defaultValue = "STUDY") MissionCategory category, + @Schema(description = "미션 공개여부", defaultValue = "ALL") MissionVisibility visibility, + @Schema(description = "미션 아카이빙 상태", defaultValue = "NONE") ArchiveStatus archiveStatus, + @Schema(description = "미션 정렬 값", defaultValue = "1") Integer sort, + @Schema(description = "미션 상태", defaultValue = "1") MissionStatus missionStatus, + @Schema(description = "인증 TTL 종료 시간", defaultValue = "NONE") LocalDateTime ttlFinishedAt, + @Schema(description = "인증필요인경우 recordId", defaultValue = "1") Long missionRecordId) { + + public static MissionFindAllResponse of( + Mission mission, + MissionStatus missionStatus, + LocalDateTime ttlFinishedAt, + Long missionRecordId) { + return new MissionFindAllResponse( + mission.getId(), + mission.getName(), + mission.getContent(), + mission.getCategory(), + mission.getVisibility(), + mission.getArchiveStatus(), + mission.getSort(), + missionStatus, + ttlFinishedAt, + missionRecordId); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/MissionFindResponse.java b/src/main/java/com/depromeet/domain/mission/dto/response/MissionFindResponse.java new file mode 100644 index 000000000..abdf4460e --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/MissionFindResponse.java @@ -0,0 +1,28 @@ +package com.depromeet.domain.mission.dto.response; + +import com.depromeet.domain.mission.domain.ArchiveStatus; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MissionFindResponse( + @Schema(description = "미션 ID", defaultValue = "1") Long missionId, + @Schema(description = "미션 이름", defaultValue = "default name") String name, + @Schema(description = "미션 내용", defaultValue = "default content") String content, + @Schema(description = "미션 카테고리", defaultValue = "STUDY") MissionCategory category, + @Schema(description = "미션 공개여부", defaultValue = "ALL") MissionVisibility visibility, + @Schema(description = "미션 아카이빙 상태", defaultValue = "NONE") ArchiveStatus status, + @Schema(description = "미션 정렬 값", defaultValue = "1") Integer sort) { + + public static MissionFindResponse from(Mission mission) { + return new MissionFindResponse( + mission.getId(), + mission.getName(), + mission.getContent(), + mission.getCategory(), + mission.getVisibility(), + mission.getArchiveStatus(), + mission.getSort()); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/MissionStatus.java b/src/main/java/com/depromeet/domain/mission/dto/response/MissionStatus.java new file mode 100644 index 000000000..358ad73ca --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/MissionStatus.java @@ -0,0 +1,15 @@ +package com.depromeet.domain.mission.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MissionStatus { + NONE("미완료"), + REQUIRED("인증필요"), + COMPLETED("완료"), + ; + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/MissionUpdateResponse.java b/src/main/java/com/depromeet/domain/mission/dto/response/MissionUpdateResponse.java new file mode 100644 index 000000000..e2cec6a59 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/MissionUpdateResponse.java @@ -0,0 +1,11 @@ +package com.depromeet.domain.mission.dto.response; + +import com.depromeet.domain.mission.domain.Mission; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MissionUpdateResponse( + @Schema(description = "미션 ID", defaultValue = "1") Long missionId) { + public static MissionUpdateResponse from(Mission mission) { + return new MissionUpdateResponse(mission.getId()); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/exception/.gitkeep b/src/main/java/com/depromeet/domain/mission/exception/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/main/java/com/depromeet/domain/missionRecord/api/MissionRecordController.java b/src/main/java/com/depromeet/domain/missionRecord/api/MissionRecordController.java new file mode 100644 index 000000000..633209c87 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/api/MissionRecordController.java @@ -0,0 +1,64 @@ +package com.depromeet.domain.missionRecord.api; + +import com.depromeet.domain.missionRecord.application.MissionRecordService; +import com.depromeet.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.domain.missionRecord.dto.request.MissionRecordUpdateRequest; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordCreateResponse; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordFindOneResponse; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordFindResponse; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordUpdateResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "3. [미션 기록]", description = "미션 기록 관련 API") +@RestController +@RequestMapping("/records") +@RequiredArgsConstructor +public class MissionRecordController { + private final MissionRecordService missionRecordService; + + @Operation(summary = "미션 기록 생성", description = "미션 기록을 생성하고 생성 된 id를 반환합니다.") + @PostMapping + public ResponseEntity missionRecordCreate( + @Valid @RequestBody MissionRecordCreateRequest request) { + MissionRecordCreateResponse response = missionRecordService.createMissionRecord(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "미션 기록 조회", description = "미션 기록을 조회합니다.") + @GetMapping("/{recordId}") + public MissionRecordFindOneResponse missionRecordFindOne(@PathVariable Long recordId) { + return missionRecordService.findOneMissionRecord(recordId); + } + + @Operation(summary = "미션 기록 조회 (캘린더 뷰)", description = "미션 기록을 조회합니다.") + @GetMapping + public List missionRecordFind( + @RequestParam("missionId") Long missionId, + @RequestParam("yearMonth") YearMonth yearMonth) { + return missionRecordService.findAllMissionRecord(missionId, yearMonth); + } + + @Operation(summary = "미션 기록 단건 수정", description = "미션 기록을 수정합니다.") + @PutMapping("/{recordId}") + public MissionRecordUpdateResponse missionRecordUpdate( + @Valid @RequestBody MissionRecordUpdateRequest request, @PathVariable Long recordId) { + return missionRecordService.updateMissionRecord(request, recordId); + } + + @Operation( + summary = "이미 진행중인 미션 기록들 삭제", + description = "이미 진행중인 미션 기록들을 삭제합니다. (인증 필요인 경우만 삭제)") + @DeleteMapping("/in-progress") + public ResponseEntity missionRecordInProgressDelete() { + missionRecordService.deleteInProgressMissionRecord(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java b/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java new file mode 100644 index 000000000..2c0b82075 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java @@ -0,0 +1,157 @@ +package com.depromeet.domain.missionRecord.application; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.dao.MissionRecordTtlRepository; +import com.depromeet.domain.missionRecord.domain.ImageUploadStatus; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.domain.missionRecord.domain.MissionRecordTtl; +import com.depromeet.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.domain.missionRecord.dto.request.MissionRecordUpdateRequest; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordCreateResponse; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordFindOneResponse; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordFindResponse; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordUpdateResponse; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.util.MemberUtil; +import java.time.Duration; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MissionRecordService { + private static final int EXPIRATION_TIME = 10; + + private final MemberUtil memberUtil; + private final MissionRepository missionRepository; + private final MissionRecordRepository missionRecordRepository; + private final MissionRecordTtlRepository missionRecordTtlRepository; + + public MissionRecordCreateResponse createMissionRecord(MissionRecordCreateRequest request) { + final Mission mission = findMissionById(request.missionId()); + final Member member = memberUtil.getCurrentMember(); + + Duration duration = + Duration.ofMinutes(request.durationMin()).plusSeconds(request.durationSec()); + + validateMissionRecordUserMismatch(mission, member); + validateMissionRecordDuration(duration); + validateMissionRecordExistsToday(mission.getId()); + + MissionRecord missionRecord = + MissionRecord.createMissionRecord( + duration, request.startedAt(), request.finishedAt(), mission); + Long expirationTime = + Duration.between( + request.finishedAt(), + request.finishedAt().plusMinutes(EXPIRATION_TIME)) + .getSeconds(); + MissionRecord createdMissionRecord = missionRecordRepository.save(missionRecord); + missionRecordTtlRepository.save( + MissionRecordTtl.createMissionRecordTtl( + createdMissionRecord.getId(), + expirationTime, + request.finishedAt().plusMinutes(EXPIRATION_TIME))); + return MissionRecordCreateResponse.from(createdMissionRecord.getId()); + } + + private void validateMissionRecordExistsToday(Long missionId) { + if (missionRecordRepository.isCompletedMissionExistsToday(missionId)) { + throw new CustomException(ErrorCode.MISSION_RECORD_ALREADY_EXISTS_TODAY); + } + } + + private Mission findMissionById(Long missionId) { + return missionRepository + .findById(missionId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND)); + } + + @Transactional(readOnly = true) + public MissionRecordFindOneResponse findOneMissionRecord(Long recordId) { + MissionRecord missionRecord = + missionRecordRepository + .findById(recordId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND)); + return MissionRecordFindOneResponse.from(missionRecord); + } + + @Transactional(readOnly = true) + public List findAllMissionRecord( + Long missionId, YearMonth yearMonth) { + List missionRecords = + missionRecordRepository.findAllByMissionIdAndYearMonth(missionId, yearMonth); + return missionRecords.stream().map(MissionRecordFindResponse::from).toList(); + } + + public MissionRecordUpdateResponse updateMissionRecord( + MissionRecordUpdateRequest request, Long recordId) { + final Member member = memberUtil.getCurrentMember(); + MissionRecord missionRecord = + missionRecordRepository + .findById(recordId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND)); + + validateMissionRecordUserMismatch(missionRecord.getMission(), member); + + missionRecord.updateMissionRecord(request.remark()); + return MissionRecordUpdateResponse.from(missionRecord); + } + + public void deleteInProgressMissionRecord() { + final Member currentMember = memberUtil.getCurrentMember(); + final LocalDate today = LocalDate.now(); + + List missions = missionRepository.findMissionsWithRecords(currentMember.getId()); + + for (Mission mission : missions) { + List records = mission.getMissionRecords(); + + Optional optionalRecord = + records.stream() + .filter(record -> record.getStartedAt().toLocalDate().equals(today)) + .findFirst(); + + // 당일 수행한 미션기록이 없으면 NONE + if (optionalRecord.isEmpty()) { + continue; + } + + // 당일 수행한 미션기록의 인증사진이 존재하면 COMPLETE + if (optionalRecord.get().getUploadStatus() == ImageUploadStatus.COMPLETE) { + continue; + } + + // 레디스에 미션기록의 인증사진 인증 대기시간 값이 존재하면 REQUIRED + Optional missionRecordTTL = + missionRecordTtlRepository.findById(optionalRecord.get().getId()); + + if (missionRecordTTL.isPresent()) { + missionRecordTtlRepository.deleteById(optionalRecord.get().getId()); + mission.getMissionRecords().remove(optionalRecord.get()); // use orphanRemoval + } + } + } + + private void validateMissionRecordDuration(Duration duration) { + if (duration.getSeconds() > 3600L) { + throw new CustomException(ErrorCode.MISSION_RECORD_DURATION_OVERBALANCE); + } + } + + private void validateMissionRecordUserMismatch(Mission mission, Member member) { + if (!mission.getMember().getId().equals(member.getId())) { + throw new CustomException(ErrorCode.MISSION_RECORD_USER_MISMATCH); + } + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/application/RedisExpireEventRedisMessageListener.java b/src/main/java/com/depromeet/domain/missionRecord/application/RedisExpireEventRedisMessageListener.java new file mode 100644 index 000000000..81fa0d672 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/application/RedisExpireEventRedisMessageListener.java @@ -0,0 +1,34 @@ +package com.depromeet.domain.missionRecord.application; + +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.global.common.constants.RedisExpireEventConstants; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisExpireEventRedisMessageListener implements MessageListener { + private final MissionRecordRepository missionRecordRepository; + + @Override + public void onMessage(Message message, byte[] pattern) { + String patternStr = new String(pattern); + log.info( + "RedisExpireEventRedisMessageListener.onMessage : message = {}, pattern = {}", + message.toString(), + patternStr); + if (!patternStr.equals(RedisExpireEventConstants.REDIS_EXPIRE_EVENT_PATTERN.getValue())) { + return; + } + String redisEntityName = message.toString().split(":")[0]; + if (!redisEntityName.equals("MissionRecordTtl")) { + return; + } + Long missionRecordId = Long.parseLong(message.toString().split(":")[1]); + missionRecordRepository.deleteById(missionRecordId); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepository.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepository.java new file mode 100644 index 000000000..d8b6ff960 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepository.java @@ -0,0 +1,7 @@ +package com.depromeet.domain.missionRecord.dao; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MissionRecordRepository + extends JpaRepository, MissionRecordRepositoryCustom {} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java new file mode 100644 index 000000000..ee09b5e1e --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.depromeet.domain.missionRecord.dao; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import java.time.YearMonth; +import java.util.List; + +public interface MissionRecordRepositoryCustom { + + List findAllByMissionIdAndYearMonth(Long missionId, YearMonth yearMonth); + + boolean isCompletedMissionExistsToday(Long missionId); + + void deleteByMissionRecordId(Long missionRecordId); +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java new file mode 100644 index 000000000..498e8de8f --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.depromeet.domain.missionRecord.dao; + +import static com.depromeet.domain.missionRecord.domain.QMissionRecord.*; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MissionRecordRepositoryImpl implements MissionRecordRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findAllByMissionIdAndYearMonth(Long missionId, YearMonth yearMonth) { + return jpaQueryFactory + .selectFrom(missionRecord) + .where( + missionIdEq(missionId), + yearEq(yearMonth.getYear()), + monthEq(yearMonth.getMonthValue())) + .orderBy(missionRecord.startedAt.asc()) + .fetch(); + } + + @Override + public boolean isCompletedMissionExistsToday(Long missionId) { + LocalDate now = LocalDate.now(); + MissionRecord missionRecordFetchOne = + jpaQueryFactory + .selectFrom(missionRecord) + .where( + missionIdEq(missionId), + yearEq(now.getYear()), + monthEq(now.getMonthValue()), + dayEq(now.getDayOfMonth())) + .fetchFirst(); + return missionRecordFetchOne != null; + } + + @Override + public void deleteByMissionRecordId(Long missionRecordId) { + jpaQueryFactory.delete(missionRecord).where(missionRecord.id.eq(missionRecordId)).execute(); + } + + private BooleanExpression missionIdEq(Long missionId) { + return missionRecord.mission.id.eq(missionId); + } + + private BooleanExpression yearEq(int year) { + return missionRecord.startedAt.year().eq(year); + } + + private BooleanExpression monthEq(int month) { + return missionRecord.startedAt.month().eq(month); + } + + private BooleanExpression dayEq(int day) { + return missionRecord.startedAt.dayOfMonth().eq(day); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordTtlRepository.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordTtlRepository.java new file mode 100644 index 000000000..92f0fda97 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordTtlRepository.java @@ -0,0 +1,6 @@ +package com.depromeet.domain.missionRecord.dao; + +import com.depromeet.domain.missionRecord.domain.MissionRecordTtl; +import org.springframework.data.repository.CrudRepository; + +public interface MissionRecordTtlRepository extends CrudRepository {} diff --git a/src/main/java/com/depromeet/domain/missionRecord/domain/ImageUploadStatus.java b/src/main/java/com/depromeet/domain/missionRecord/domain/ImageUploadStatus.java new file mode 100644 index 000000000..886332203 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/domain/ImageUploadStatus.java @@ -0,0 +1,14 @@ +package com.depromeet.domain.missionRecord.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageUploadStatus { + NONE("업로드 없음"), + PENDING("업로드 중"), + COMPLETE("업로드 완료"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/domain/MissionRecord.java b/src/main/java/com/depromeet/domain/missionRecord/domain/MissionRecord.java new file mode 100644 index 000000000..f5d9b5901 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/domain/MissionRecord.java @@ -0,0 +1,102 @@ +package com.depromeet.domain.missionRecord.domain; + +import com.depromeet.domain.common.model.BaseTimeEntity; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MissionRecord extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "mission_record_id") + private Long id; + + private Duration duration; + + @Comment("미션 일지") + private String remark; + + @Comment("인증 사진") + private String imageUrl; + + @Enumerated(EnumType.STRING) + private ImageUploadStatus uploadStatus; + + private LocalDateTime startedAt; + + private LocalDateTime finishedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mission_id") + private Mission mission; + + @Builder(access = AccessLevel.PRIVATE) + private MissionRecord( + Duration duration, + String remark, + String imageUrl, + ImageUploadStatus uploadStatus, + LocalDateTime startedAt, + LocalDateTime finishedAt, + Mission mission) { + this.duration = duration; + this.remark = remark; + this.uploadStatus = uploadStatus; + this.imageUrl = imageUrl; + this.startedAt = startedAt; + this.finishedAt = finishedAt; + this.mission = mission; + } + + public static MissionRecord createMissionRecord( + Duration duration, LocalDateTime startedAt, LocalDateTime finishedAt, Mission mission) { + return MissionRecord.builder() + .duration(duration) + .uploadStatus(ImageUploadStatus.NONE) + .startedAt(startedAt) + .finishedAt(finishedAt) + .mission(mission) + .build(); + } + + public void updateUploadStatusPending() { + if (this.uploadStatus != ImageUploadStatus.NONE) { + throw new CustomException(ErrorCode.MISSION_RECORD_UPLOAD_STATUS_IS_NOT_NONE); + } + this.uploadStatus = ImageUploadStatus.PENDING; + } + + public void updateUploadStatusComplete(String remark, String imageUrl) { + if (this.uploadStatus != ImageUploadStatus.PENDING) { + throw new CustomException(ErrorCode.MISSION_RECORD_UPLOAD_STATUS_IS_NOT_PENDING); + } + this.uploadStatus = ImageUploadStatus.COMPLETE; + this.remark = remark; + this.imageUrl = imageUrl; + } + + public void updateMissionRecord(String remark) { + this.remark = remark; + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/domain/MissionRecordTtl.java b/src/main/java/com/depromeet/domain/missionRecord/domain/MissionRecordTtl.java new file mode 100644 index 000000000..6c9a30306 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/domain/MissionRecordTtl.java @@ -0,0 +1,37 @@ +package com.depromeet.domain.missionRecord.domain; + +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("MissionRecordTtl") +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MissionRecordTtl { + @Id private Long key; + + @TimeToLive private Long timeToLive; + + private LocalDateTime ttlFinishedAt; + + @Builder(access = AccessLevel.PRIVATE) + public MissionRecordTtl(Long key, Long timeToLive, LocalDateTime ttlFinishedAt) { + this.key = key; + this.timeToLive = timeToLive; + this.ttlFinishedAt = ttlFinishedAt; + } + + public static MissionRecordTtl createMissionRecordTtl( + Long key, Long timeToLive, LocalDateTime ttlFinishedAt) { + return MissionRecordTtl.builder() + .key(key) + .timeToLive(timeToLive) + .ttlFinishedAt(ttlFinishedAt) + .build(); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/request/MissionRecordCreateRequest.java b/src/main/java/com/depromeet/domain/missionRecord/dto/request/MissionRecordCreateRequest.java new file mode 100644 index 000000000..a36bc3bd9 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/request/MissionRecordCreateRequest.java @@ -0,0 +1,43 @@ +package com.depromeet.domain.missionRecord.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record MissionRecordCreateRequest( + @NotNull(message = "미션 아이디는 비워둘 수 없습니다.") + @Schema(description = "미션 아이디", defaultValue = "1") + Long missionId, + @NotNull(message = "미션 기록 시작 시간은 비워둘 수 없습니다.") + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 시작 시간", + defaultValue = "2023-01-03 00:00:00", + type = "string") + LocalDateTime startedAt, + @NotNull(message = "미션 기록 종료 시간은 비워둘 수 없습니다.") + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 종료 시간", + defaultValue = "2024-01-03 00:34:00", + type = "string") + LocalDateTime finishedAt, + @NotNull(message = "미션 참여 시간(분)은 비워둘 수 없습니다.") + @Min(value = 0, message = "미션 참여 시간(분)의 최소 값은 0입니다.") + @Max(value = 60, message = "미션 참여 시간(분)의 최대값은 60입니다.") + @Schema(description = "미션 참여 시간(분)", defaultValue = "32") + Integer durationMin, + @NotNull(message = "미션 참여 시간(초)은 비워둘 수 없습니다.") + @Min(value = 0, message = "미션 참여 시간(초)의 최소값는 0입니다.") + @Max(value = 60, message = "미션 참여 시간(초)의 최대값는 60입니다.") + @Schema(description = "미션 참여 시간(초)", defaultValue = "14") + Integer durationSec) {} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/request/MissionRecordUpdateRequest.java b/src/main/java/com/depromeet/domain/missionRecord/dto/request/MissionRecordUpdateRequest.java new file mode 100644 index 000000000..0a7421cf4 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/request/MissionRecordUpdateRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.domain.missionRecord.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +public record MissionRecordUpdateRequest( + @Size(min = 0, max = 200, message = "미션 기록 일지는 200자까지") + @Schema(description = "미션 기록 일지", defaultValue = "default missionRecord remark") + String remark) {} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordCreateResponse.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordCreateResponse.java new file mode 100644 index 000000000..5794b8f4d --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordCreateResponse.java @@ -0,0 +1,11 @@ +package com.depromeet.domain.missionRecord.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MissionRecordCreateResponse( + @Schema(description = "미션 기록 ID", defaultValue = "1") Long missionId) { + + public static MissionRecordCreateResponse from(Long missionId) { + return new MissionRecordCreateResponse(missionId); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordFindOneResponse.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordFindOneResponse.java new file mode 100644 index 000000000..71c0a5e47 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordFindOneResponse.java @@ -0,0 +1,47 @@ +package com.depromeet.domain.missionRecord.dto.response; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Duration; +import java.time.LocalDateTime; + +public record MissionRecordFindOneResponse( + @Schema(description = "미션 기록 ID", defaultValue = "1") Long recordId, + @Schema(description = "미션 기록 일지", defaultValue = "default MissionRecord Remark") + String remark, + @Schema( + description = "미션 기록 인증 사진 Url", + defaultValue = "https://ik.imagekit.io/demo/medium_cafe_B1iTdD0C.jpg") + String imageUrl, + @Schema(description = "미션 수행한 시간", defaultValue = "21") long duration, + @Schema(description = "미션 시작한 지 N일차", defaultValue = "3") long sinceDay, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 시작 시간", + defaultValue = "2023-01-03 00:00:00", + type = "string") + LocalDateTime startedAt, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 종료 시간", + defaultValue = "2024-01-03 00:34:00", + type = "string") + LocalDateTime finishedAt) { + public static MissionRecordFindOneResponse from(MissionRecord missionRecord) { + return new MissionRecordFindOneResponse( + missionRecord.getId(), + missionRecord.getRemark(), + missionRecord.getImageUrl(), + missionRecord.getDuration().toMinutes(), + Duration.between(missionRecord.getStartedAt(), LocalDateTime.now()).toDays(), + missionRecord.getStartedAt(), + missionRecord.getFinishedAt()); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordFindResponse.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordFindResponse.java new file mode 100644 index 000000000..d26253c34 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordFindResponse.java @@ -0,0 +1,44 @@ +package com.depromeet.domain.missionRecord.dto.response; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record MissionRecordFindResponse( + @Schema(description = "미션 기록 ID", defaultValue = "1") Long recordId, + @Schema(description = "미션 기록 일지", defaultValue = "default MissionRecord Remark") + String remark, + @Schema( + description = "미션 기록 인증 사진 Url", + defaultValue = "https://ik.imagekit.io/demo/medium_cafe_B1iTdD0C.jpg") + String imageUrl, + @Schema(description = "미션 시작 일자", defaultValue = "3") int missionDay, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 시작 시간", + defaultValue = "2023-01-03 00:00:00", + type = "string") + LocalDateTime startedAt, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 종료 시간", + defaultValue = "2024-01-03 00:34:00", + type = "string") + LocalDateTime finishedAt) { + public static MissionRecordFindResponse from(MissionRecord missionRecord) { + return new MissionRecordFindResponse( + missionRecord.getId(), + missionRecord.getRemark(), + missionRecord.getImageUrl(), + missionRecord.getStartedAt().getDayOfMonth(), + missionRecord.getStartedAt(), + missionRecord.getFinishedAt()); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordUpdateResponse.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordUpdateResponse.java new file mode 100644 index 000000000..a350394f3 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordUpdateResponse.java @@ -0,0 +1,11 @@ +package com.depromeet.domain.missionRecord.dto.response; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MissionRecordUpdateResponse( + @Schema(description = "미션 기록 ID", defaultValue = "1") Long recordId) { + public static MissionRecordUpdateResponse from(MissionRecord missionRecord) { + return new MissionRecordUpdateResponse(missionRecord.getId()); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/exception/.gitkeep b/src/main/java/com/depromeet/domain/missionRecord/exception/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/main/java/com/depromeet/domain/model/.gitkeep b/src/main/java/com/depromeet/domain/model/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/main/java/com/depromeet/global/common/constants/EnvironmentConstants.java b/src/main/java/com/depromeet/global/common/constants/EnvironmentConstants.java new file mode 100644 index 000000000..8ee41f6d6 --- /dev/null +++ b/src/main/java/com/depromeet/global/common/constants/EnvironmentConstants.java @@ -0,0 +1,15 @@ +package com.depromeet.global.common.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum EnvironmentConstants { + PROD("prod"), + DEV("dev"), + LOCAL("local"), + ; + + private String value; +} diff --git a/src/main/java/com/depromeet/global/common/constants/RedisExpireEventConstants.java b/src/main/java/com/depromeet/global/common/constants/RedisExpireEventConstants.java new file mode 100644 index 000000000..57a06cc16 --- /dev/null +++ b/src/main/java/com/depromeet/global/common/constants/RedisExpireEventConstants.java @@ -0,0 +1,12 @@ +package com.depromeet.global.common.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RedisExpireEventConstants { + REDIS_EXPIRE_EVENT_PATTERN("__keyevent@*__:expired"), + ; + private final String value; +} diff --git a/src/main/java/com/depromeet/global/common/constants/SecurityConstants.java b/src/main/java/com/depromeet/global/common/constants/SecurityConstants.java new file mode 100644 index 000000000..6c111201b --- /dev/null +++ b/src/main/java/com/depromeet/global/common/constants/SecurityConstants.java @@ -0,0 +1,12 @@ +package com.depromeet.global.common.constants; + +public final class SecurityConstants { + + private SecurityConstants() {} + + public static final String TOKEN_ROLE_NAME = "role"; + public static final String TOKEN_PREFIX = "Bearer "; + public static final String ACCESS_TOKEN_HEADER = "Authorization"; + public static final String REFRESH_TOKEN_HEADER = "Refresh-Token"; + public static final String REGISTER_REQUIRED_HEADER = "Registration-Required"; +} diff --git a/src/main/java/com/depromeet/global/common/constants/SwaggerUrlConstants.java b/src/main/java/com/depromeet/global/common/constants/SwaggerUrlConstants.java new file mode 100644 index 000000000..1e1d39999 --- /dev/null +++ b/src/main/java/com/depromeet/global/common/constants/SwaggerUrlConstants.java @@ -0,0 +1,15 @@ +package com.depromeet.global.common.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SwaggerUrlConstants { + SWAGGER_RESOURCES_URL("/swagger-resources/**"), + SWAGGER_UI_URL("/swagger-ui/**"), + SWAGGER_API_DOCS_URL("/v3/api-docs/**"), + ; + + private String value; +} diff --git a/src/main/java/com/depromeet/global/common/constants/UrlConstants.java b/src/main/java/com/depromeet/global/common/constants/UrlConstants.java new file mode 100644 index 000000000..0bac049c9 --- /dev/null +++ b/src/main/java/com/depromeet/global/common/constants/UrlConstants.java @@ -0,0 +1,19 @@ +package com.depromeet.global.common.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UrlConstants { + PROD_SERVER_URL("https://api.10mm.today"), + DEV_SERVER_URL("https://dev-api.10mm.today"), + LOCAL_SERVER_URL("http://localhost:8080"), + + PROD_DOMAIN_URL("https://www.10mm.today"), + DEV_DOMAIN_URL("https://www.dev.10mm.today"), + LOCAL_DOMAIN_URL("http://localhost:3000"), + ; + + private String value; +} diff --git a/src/main/java/com/depromeet/global/common/request/.gitkeep b/src/main/java/com/depromeet/global/common/request/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/main/java/com/depromeet/global/common/response/GlobalResponse.java b/src/main/java/com/depromeet/global/common/response/GlobalResponse.java new file mode 100644 index 000000000..de55cf52b --- /dev/null +++ b/src/main/java/com/depromeet/global/common/response/GlobalResponse.java @@ -0,0 +1,14 @@ +package com.depromeet.global.common.response; + +import com.depromeet.global.error.ErrorResponse; +import java.time.LocalDateTime; + +public record GlobalResponse(boolean success, int status, Object data, LocalDateTime timestamp) { + public static GlobalResponse success(int status, Object data) { + return new GlobalResponse(true, status, data, LocalDateTime.now()); + } + + public static GlobalResponse fail(int status, ErrorResponse errorResponse) { + return new GlobalResponse(false, status, errorResponse, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/depromeet/global/common/response/GlobalResponseAdvice.java b/src/main/java/com/depromeet/global/common/response/GlobalResponseAdvice.java new file mode 100644 index 000000000..1fcac9ba1 --- /dev/null +++ b/src/main/java/com/depromeet/global/common/response/GlobalResponseAdvice.java @@ -0,0 +1,40 @@ +package com.depromeet.global.common.response; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RestControllerAdvice(basePackages = "com.depromeet") +public class GlobalResponseAdvice implements ResponseBodyAdvice { + @Override + public boolean supports(MethodParameter returnType, Class converterType) { + return true; + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + HttpServletResponse servletResponse = + ((ServletServerHttpResponse) response).getServletResponse(); + int status = servletResponse.getStatus(); + HttpStatus resolve = HttpStatus.resolve(status); + if (resolve == null || body instanceof String) { + return body; + } + if (resolve.is2xxSuccessful()) { + return GlobalResponse.success(status, body); + } + return body; + } +} diff --git a/src/main/java/com/depromeet/global/config/querydsl/QueryDslConfig.java b/src/main/java/com/depromeet/global/config/querydsl/QueryDslConfig.java new file mode 100644 index 000000000..fadafcac1 --- /dev/null +++ b/src/main/java/com/depromeet/global/config/querydsl/QueryDslConfig.java @@ -0,0 +1,18 @@ +package com.depromeet.global.config.querydsl; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext public EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java b/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java new file mode 100644 index 000000000..47657a1f5 --- /dev/null +++ b/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java @@ -0,0 +1,177 @@ +package com.depromeet.global.config.security; + +import static org.springframework.security.config.Customizer.*; + +import com.depromeet.global.common.constants.SwaggerUrlConstants; +import com.depromeet.global.common.constants.UrlConstants; +import com.depromeet.global.security.*; +import com.depromeet.global.util.SpringEnvironmentUtil; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + + private final SpringEnvironmentUtil springEnvironmentUtil; + // private final CustomOidcUserService customOidcUserService; + // private final CustomOidcAuthenticationSuccessHandler + // customOidcAuthenticationSuccessHandler; + // private final CustomOidcAuthenticationFailureHandler + // customOidcAuthenticationFailureHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + // private final CustomRequestEntityConverterV2 customRequestEntityConverterV2; + + @Value("${swagger.user}") + private String swaggerUser; + + @Value("${swagger.password}") + private String swaggerPassword; + + @Bean + public InMemoryUserDetailsManager inMemoryUserDetailsManager() { + UserDetails user = + User.withUsername(swaggerUser) + .password(passwordEncoder().encode(swaggerPassword)) + .roles("SWAGGER") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http.httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .cors(withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + if (springEnvironmentUtil.isProdAndDevProfile()) { + http.authorizeHttpRequests( + authorize -> + authorize + .requestMatchers( + HttpMethod.GET, + Arrays.stream(SwaggerUrlConstants.values()) + .map(SwaggerUrlConstants::getValue) + .toArray(String[]::new)) + .authenticated()) + .httpBasic(withDefaults()); + } + + http.authorizeHttpRequests( + authorize -> + authorize + .requestMatchers( + HttpMethod.GET, + Arrays.stream(SwaggerUrlConstants.values()) + .map(SwaggerUrlConstants::getValue) + .toArray(String[]::new)) + .permitAll() + .requestMatchers("/10mm-actuator/**") + .permitAll() // 액추에이터 + .requestMatchers("/auth/register") + .authenticated() // 소셜 로그인 임시 토큰으로 인증 + .requestMatchers("/auth/**") + .permitAll() // 임시 회원가입 / 로그인은 토큰 필요 X + .requestMatchers("/v1/**") + .permitAll() // 임시로 모든 요청 허용 + .requestMatchers("/oauth2/**") + .permitAll() + .anyRequest() + // TODO: 임시로 모든 url 허용했지만, OIDC에서 권한따라 authentication 할 수 있도록 변경 필요 + .authenticated()); + // .permitAll()); + + // TODO: 소셜 로그인은 별도 처리 + + // http.oauth2Login( + // oauth2 -> + // oauth2.tokenEndpoint( + // tokenEndpoint -> + // tokenEndpoint.accessTokenResponseClient( + // + // customAccessTokenResponseClient())) + // .userInfoEndpoint( + // userInfo -> + // userInfo.oidcUserService(customOidcUserService)) + // .successHandler(customOidcAuthenticationSuccessHandler) + // .failureHandler(customOidcAuthenticationFailureHandler) + // .userInfoEndpoint( + // userInfo -> + // userInfo.oidcUserService(customOidcUserService)) + // .successHandler(customOidcAuthenticationSuccessHandler) + // .failureHandler(customOidcAuthenticationFailureHandler)); + + // http.addFilterAfter(jwtAuthenticationFilter, LogoutFilter.class); + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + switch (springEnvironmentUtil.getCurrentProfile()) { + case "prod": + configuration.addAllowedOriginPattern(UrlConstants.PROD_DOMAIN_URL.getValue()); + break; + // TODO: 프론트 모바일에서 웹뷰 테스트를 위해 임시 주석 처리 + // case "dev": + // + // configuration.addAllowedOriginPattern(UrlConstants.DEV_DOMAIN_URL.getValue()); + // + // configuration.addAllowedOriginPattern(UrlConstants.LOCAL_DOMAIN_URL.getValue()); + // break; + default: + configuration.addAllowedOriginPattern("*"); + break; + } + + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + configuration.setAllowCredentials(true); + configuration.addExposedHeader("Set-Cookie"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + // TODO: 소셜 로그인 추가 시 빈으로 등록 + + // public DefaultAuthorizationCodeTokenResponseClient customAccessTokenResponseClient() { + // DefaultAuthorizationCodeTokenResponseClient client = + // new DefaultAuthorizationCodeTokenResponseClient(); + // client.setRequestEntityConverter(customRequestEntityConverterV2); + // return client; + // } +} diff --git a/src/main/java/com/depromeet/global/config/swagger/SwaggerConfig.java b/src/main/java/com/depromeet/global/config/swagger/SwaggerConfig.java new file mode 100644 index 000000000..dbaabc28b --- /dev/null +++ b/src/main/java/com/depromeet/global/config/swagger/SwaggerConfig.java @@ -0,0 +1,97 @@ +package com.depromeet.global.config.swagger; + +import static com.depromeet.global.util.SpringEnvironmentUtil.*; + +import com.depromeet.global.common.constants.UrlConstants; +import com.depromeet.global.util.SpringEnvironmentUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.core.jackson.ModelResolver; +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.info.License; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import io.swagger.v3.oas.models.security.SecurityScheme.Type; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class SwaggerConfig { + private static final String SERVER_NAME = "10MM"; + private static final String API_TITLE = "10MM 서버 API 문서"; + private static final String API_DESCRIPTION = "10MM 서버 API 문서입니다."; + private static final String GITHUB_URL = "https://github.com/depromeet/10mm-server"; + + private final SpringEnvironmentUtil springEnvironmentUtil; + + @Value("${swagger.version}") + private String version; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .servers(swaggerServers()) + .components(authSetting()) + .info(swaggerInfo()); + } + + private List swaggerServers() { + Server server = new Server().url(getServerUrl()).description(API_DESCRIPTION); + return List.of(server); + } + + private String getServerUrl() { + switch (springEnvironmentUtil.getCurrentProfile()) { + case "prod": + return UrlConstants.PROD_SERVER_URL.getValue(); + case "dev": + return UrlConstants.DEV_SERVER_URL.getValue(); + default: + return UrlConstants.LOCAL_SERVER_URL.getValue(); + } + } + + private Components authSetting() { + return new Components() + .addSecuritySchemes( + "accessToken", + new SecurityScheme() + .type(Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(In.HEADER) + .name("Authorization")) + .addSecuritySchemes( + "refreshToken", + new SecurityScheme() + .type(Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(In.HEADER) + .name("Refresh-Token")); + } + + private Info swaggerInfo() { + License license = new License(); + license.setUrl(GITHUB_URL); + license.setName(SERVER_NAME); + + return new Info() + .version("v" + version) + .title(API_TITLE) + .description(API_DESCRIPTION) + .license(license); + } + + @Bean + public ModelResolver modelResolver(ObjectMapper objectMapper) { + // 객체 직렬화 + return new ModelResolver(objectMapper); + } +} diff --git a/src/main/java/com/depromeet/global/error/ErrorResponse.java b/src/main/java/com/depromeet/global/error/ErrorResponse.java new file mode 100644 index 000000000..5863e3b94 --- /dev/null +++ b/src/main/java/com/depromeet/global/error/ErrorResponse.java @@ -0,0 +1,8 @@ +package com.depromeet.global.error; + +public record ErrorResponse(String errorClassName, String message) { + + public static ErrorResponse of(String errorClassName, String message) { + return new ErrorResponse(errorClassName, message); + } +} diff --git a/src/main/java/com/depromeet/global/error/GlobalExceptionHandler.java b/src/main/java/com/depromeet/global/error/GlobalExceptionHandler.java new file mode 100644 index 000000000..be11d409f --- /dev/null +++ b/src/main/java/com/depromeet/global/error/GlobalExceptionHandler.java @@ -0,0 +1,144 @@ +package com.depromeet.global.error; + +import com.depromeet.global.common.response.GlobalResponse; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import jakarta.validation.ConstraintViolationException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @Override + protected ResponseEntity handleExceptionInternal( + Exception ex, + Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest request) { + ErrorResponse errorResponse = + ErrorResponse.of(ex.getClass().getSimpleName(), ex.getMessage()); + return super.handleExceptionInternal(ex, errorResponse, headers, statusCode, request); + } + + /** + * javax.validation.Valid or @Validated 으로 binding error 발생시 발생한다. HttpMessageConverter 에서 등록한 + * HttpMessageConverter binding 못할경우 발생 주로 @RequestBody, @RequestPart 어노테이션에서 발생 + */ + @SneakyThrows + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + log.error("MethodArgumentNotValidException : {}", e.getMessage(), e); + String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorMessage); + GlobalResponse response = GlobalResponse.fail(status.value(), errorResponse); + return ResponseEntity.status(status).body(response); + } + + /** Request Param Validation 예외 처리 */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException e) { + log.error("ConstraintViolationException : {}", e.getMessage(), e); + + Map bindingErrors = new HashMap<>(); + e.getConstraintViolations() + .forEach( + constraintViolation -> { + List propertyPath = + List.of( + constraintViolation + .getPropertyPath() + .toString() + .split("\\.")); + String path = + propertyPath.stream() + .skip(propertyPath.size() - 1L) + .findFirst() + .orElse(null); + bindingErrors.put(path, constraintViolation.getMessage()); + }); + + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), bindingErrors.toString()); + final GlobalResponse response = + GlobalResponse.fail(HttpStatus.BAD_REQUEST.value(), errorResponse); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + /** enum type 일치하지 않아 binding 못할 경우 발생 주로 @RequestParam enum으로 binding 못했을 경우 발생 */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + protected ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e) { + log.error("MethodArgumentTypeMismatchException : {}", e.getMessage(), e); + final ErrorCode errorCode = ErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorCode.getMessage()); + final GlobalResponse response = + GlobalResponse.fail(errorCode.getStatus().value(), errorResponse); + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + /** 지원하지 않은 HTTP method 호출 할 경우 발생 */ + @Override + protected ResponseEntity handleHttpRequestMethodNotSupported( + HttpRequestMethodNotSupportedException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + log.error("HttpRequestMethodNotSupportedException : {}", e.getMessage(), e); + final ErrorCode errorCode = ErrorCode.METHOD_NOT_ALLOWED; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), errorCode.getMessage()); + final GlobalResponse response = + GlobalResponse.fail(errorCode.getStatus().value(), errorResponse); + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + /** CustomException 예외 처리 */ + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.error("CustomException : {}", e.getMessage(), e); + final ErrorCode errorCode = e.getErrorCode(); + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.name(), errorCode.getMessage()); + final GlobalResponse response = + GlobalResponse.fail(errorCode.getStatus().value(), errorResponse); + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + /** 500번대 에러 처리 */ + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + log.error("Internal Server Error : {}", e.getMessage(), e); + final ErrorCode internalServerError = ErrorCode.INTERNAL_SERVER_ERROR; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), internalServerError.getMessage()); + final GlobalResponse response = + GlobalResponse.fail(internalServerError.getStatus().value(), errorResponse); + return ResponseEntity.status(internalServerError.getStatus()).body(response); + } +} diff --git a/src/main/java/com/depromeet/global/error/exception/CustomException.java b/src/main/java/com/depromeet/global/error/exception/CustomException.java new file mode 100644 index 000000000..831c5baea --- /dev/null +++ b/src/main/java/com/depromeet/global/error/exception/CustomException.java @@ -0,0 +1,14 @@ +package com.depromeet.global.error.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java new file mode 100644 index 000000000..095f9f451 --- /dev/null +++ b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java @@ -0,0 +1,53 @@ +package com.depromeet.global.error.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + SAMPLE_ERROR(HttpStatus.BAD_REQUEST, "Sample Error Message"), + + // Common + METHOD_ARGUMENT_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "Enum Type이 일치하지 않아 Binding에 실패하였습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP method 입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류, 관리자에게 문의하세요"), + + // Member + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다."), + MEMBER_INVALID_NORMAL(HttpStatus.FORBIDDEN, "일반 회원이 아닙니다."), + + // Security + AUTH_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "시큐리티 인증 정보를 찾을 수 없습니다."), + EXPIRED_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 JWT 토큰입니다."), + INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 JWT 토큰입니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 엑세스 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), + MEMBER_ALREADY_REGISTERED(HttpStatus.CONFLICT, "이미 가입된 회원입니다."), + MEMBER_ALREADY_NICKNAME(HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."), + MEMBER_ALREADY_DELETED(HttpStatus.NOT_FOUND, "이미 탈퇴한 회원입니다."), + SOCIAL_AUTHENTICATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류로 인해 소셜 로그인에 실패했습니다."), + INVALID_APPLE_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR, "애플 로그인에 필요한 비밀 키가 올바르지 않습니다."), + PASSWORD_NOT_MATCHES(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + GUEST_MEMBER_REQUIRES_REGISTRATION(HttpStatus.UNAUTHORIZED, "게스트 회원은 회원가입을 먼저 진행해야 합니다."), + + // Mission + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 미션을 찾을 수 없습니다."), + MISSION_VISIBILITY_NULL(HttpStatus.BAD_REQUEST, "미션 공개 여부가 null입니다."), + MISSION_STATUS_MISMATCH(HttpStatus.INTERNAL_SERVER_ERROR, "미션 상태 중 매치되지 않는 미션이 있습니다."), + + // MissionRecord + MISSION_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 미션 기록을 찾을 수 없습니다."), + MISSION_RECORD_USER_MISMATCH(HttpStatus.FORBIDDEN, "미션을 생성한 유저와 로그인된 계정이 일치하지 않습니다"), + MISSION_RECORD_DURATION_OVERBALANCE(HttpStatus.BAD_REQUEST, "미션 참여 시간이 지정 된 시간보다 초과하였습니다"), + MISSION_RECORD_UPLOAD_STATUS_IS_NOT_NONE( + HttpStatus.BAD_REQUEST, "미션 기록의 이미지 업로드 상태가 NONE이 아닙니다."), + MISSION_RECORD_UPLOAD_STATUS_IS_NOT_PENDING( + HttpStatus.BAD_REQUEST, "미션 기록의 이미지 업로드 상태가 PENDING이 아닙니다."), + MISSION_RECORD_ALREADY_EXISTS_TODAY(HttpStatus.BAD_REQUEST, "오늘 이미 작성 된 미션 기록이 존재합니다."), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/depromeet/global/security/CustomAccessTokenResponseClient.java b/src/main/java/com/depromeet/global/security/CustomAccessTokenResponseClient.java new file mode 100644 index 000000000..a54158311 --- /dev/null +++ b/src/main/java/com/depromeet/global/security/CustomAccessTokenResponseClient.java @@ -0,0 +1,24 @@ +package com.depromeet.global.security; + +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; + +public class CustomAccessTokenResponseClient + implements OAuth2AccessTokenResponseClient { + + private final DefaultAuthorizationCodeTokenResponseClient delegate = + new DefaultAuthorizationCodeTokenResponseClient(); + + public CustomAccessTokenResponseClient( + CustomRequestEntityConverterV2 customRequestEntityConverterV2) { + delegate.setRequestEntityConverter(customRequestEntityConverterV2); + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse( + OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) { + return delegate.getTokenResponse(authorizationGrantRequest); + } +} diff --git a/src/main/java/com/depromeet/global/security/CustomOidcAuthenticationFailureHandler.java b/src/main/java/com/depromeet/global/security/CustomOidcAuthenticationFailureHandler.java new file mode 100644 index 000000000..a63ca4b68 --- /dev/null +++ b/src/main/java/com/depromeet/global/security/CustomOidcAuthenticationFailureHandler.java @@ -0,0 +1,28 @@ +package com.depromeet.global.security; + +import com.depromeet.global.error.exception.ErrorCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class CustomOidcAuthenticationFailureHandler implements AuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) + throws IOException, ServletException { + + log.error("Authentication failed: {}", exception.getMessage()); + ErrorCode errorCode = ErrorCode.SOCIAL_AUTHENTICATION_FAILED; + response.sendError(errorCode.getStatus().value(), errorCode.getMessage()); + } +} diff --git a/src/main/java/com/depromeet/global/security/CustomOidcAuthenticationSuccessHandler.java b/src/main/java/com/depromeet/global/security/CustomOidcAuthenticationSuccessHandler.java new file mode 100644 index 000000000..08549712c --- /dev/null +++ b/src/main/java/com/depromeet/global/security/CustomOidcAuthenticationSuccessHandler.java @@ -0,0 +1,47 @@ +package com.depromeet.global.security; + +import static com.depromeet.global.common.constants.SecurityConstants.*; + +import com.depromeet.domain.auth.application.JwtTokenService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomOidcAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenService jwtTokenService; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException { + log.info("authentication success"); + + CustomOidcUser user = (CustomOidcUser) authentication.getPrincipal(); + + // 게스트 유저이면 회원가입 필요하므로 헤더에 담아서 응답 + response.setHeader(REGISTER_REQUIRED_HEADER, user.isGuest() ? "true" : "false"); + + String accessToken = + jwtTokenService.createAccessToken(user.getMemberId(), user.getMemberRole()); + String refreshToken = jwtTokenService.createRefreshToken(user.getMemberId()); + + // 토큰을 헤더에 담아서 응답 + setTokenPairToResponseHeader(response, accessToken, refreshToken); + } + + private void setTokenPairToResponseHeader( + HttpServletResponse response, String accessToken, String refreshToken) { + // TODO: 리프레시 토큰은 쿠키로 관리하도록 개선 + response.setHeader(ACCESS_TOKEN_HEADER, TOKEN_PREFIX + accessToken); + response.setHeader(REFRESH_TOKEN_HEADER, TOKEN_PREFIX + refreshToken); + } +} diff --git a/src/main/java/com/depromeet/global/security/CustomOidcUser.java b/src/main/java/com/depromeet/global/security/CustomOidcUser.java new file mode 100644 index 000000000..210376c94 --- /dev/null +++ b/src/main/java/com/depromeet/global/security/CustomOidcUser.java @@ -0,0 +1,23 @@ +package com.depromeet.global.security; + +import com.depromeet.domain.member.domain.MemberRole; +import lombok.Getter; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +@Getter +public class CustomOidcUser extends DefaultOidcUser { + + private final Long memberId; + private final MemberRole memberRole; + + public CustomOidcUser(OidcUser oidcUser, Long memberId, MemberRole memberRole) { + super(oidcUser.getAuthorities(), oidcUser.getIdToken(), oidcUser.getUserInfo()); + this.memberId = memberId; + this.memberRole = memberRole; + } + + public boolean isGuest() { + return MemberRole.GUEST.equals(memberRole); + } +} diff --git a/src/main/java/com/depromeet/global/security/CustomOidcUserService.java b/src/main/java/com/depromeet/global/security/CustomOidcUserService.java new file mode 100644 index 000000000..7bd5d10e6 --- /dev/null +++ b/src/main/java/com/depromeet/global/security/CustomOidcUserService.java @@ -0,0 +1,49 @@ +package com.depromeet.global.security; + +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.OauthInfo; +import java.util.Collections; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class CustomOidcUserService extends OidcUserService { + + private final MemberRepository memberRepository; + + public CustomOidcUserService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + setAccessibleScopes(Collections.emptySet()); // 빈 스코프로 설정하여 항상 UserInfo 엔드포인트에 액세스 + } + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + + OidcUser oidcUser = super.loadUser(userRequest); + Member member = fetchOrCreate(oidcUser); + + return new CustomOidcUser(oidcUser, member.getId(), member.getRole()); + } + + private Member fetchOrCreate(OidcUser oidcUser) { + return memberRepository + .findByOauthInfo(extractOauthInfo(oidcUser)) + .orElseGet(() -> saveAsGuest(oidcUser)); + } + + private Member saveAsGuest(OidcUser oidcUser) { + OauthInfo oauthInfo = extractOauthInfo(oidcUser); + Member guest = Member.createGuestMember(oauthInfo); + return memberRepository.save(guest); + } + + private OauthInfo extractOauthInfo(OidcUser oidcUser) { + return OauthInfo.createOauthInfo(oidcUser.getName(), oidcUser.getIssuer().toString()); + } +} diff --git a/src/main/java/com/depromeet/global/security/CustomRequestEntityConverter.java b/src/main/java/com/depromeet/global/security/CustomRequestEntityConverter.java new file mode 100644 index 000000000..77786b813 --- /dev/null +++ b/src/main/java/com/depromeet/global/security/CustomRequestEntityConverter.java @@ -0,0 +1,86 @@ +package com.depromeet.global.security; + +import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_SECRET; + +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.infra.config.jwt.AppleProperties; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.io.IOException; +import java.io.StringReader; +import java.security.PrivateKey; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; +import org.springframework.util.MultiValueMap; + +@Slf4j +// @Component +public class CustomRequestEntityConverter + implements Converter> { + private final AppleProperties appleProperties; + private final OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter; + + public CustomRequestEntityConverter(AppleProperties appleProperties) { + this.appleProperties = appleProperties; + this.defaultConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter(); + } + + @Override + public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest request) { + RequestEntity entity = defaultConverter.convert(request); + String registrationId = request.getClientRegistration().getRegistrationId(); + + MultiValueMap params = (MultiValueMap) entity.getBody(); + + setGeneratedClientSecretIfAppleLogin(registrationId, params); + + return new RequestEntity<>( + params, entity.getHeaders(), entity.getMethod(), entity.getUrl()); + } + + private void setGeneratedClientSecretIfAppleLogin( + String registrationId, MultiValueMap params) { + if (registrationId.equals(appleProperties.clientName())) { + try { + params.set(CLIENT_SECRET, generateClientSecret()); + } catch (IOException e) { + throw new CustomException(ErrorCode.INVALID_APPLE_PRIVATE_KEY); + } + } + } + + public PrivateKey getPrivateKey() throws IOException { + PEMParser pemParser = new PEMParser(new StringReader(appleProperties.privateKey())); + PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + return converter.getPrivateKey(object); + } + + public String generateClientSecret() throws IOException { + LocalDateTime now = LocalDateTime.now(); + + Date expireAt = Date.from(now.plusDays(30).atZone(ZoneId.systemDefault()).toInstant()); + Date issuedAt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + + return Jwts.builder() + .setHeaderParam(JwsHeader.KEY_ID, appleProperties.keyId()) + .setIssuer(appleProperties.teamId()) + .setIssuedAt(issuedAt) + .setExpiration(expireAt) + .setAudience(appleProperties.audience()) + .setSubject(appleProperties.clientId()) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } +} diff --git a/src/main/java/com/depromeet/global/security/CustomRequestEntityConverterV2.java b/src/main/java/com/depromeet/global/security/CustomRequestEntityConverterV2.java new file mode 100644 index 000000000..7ea448835 --- /dev/null +++ b/src/main/java/com/depromeet/global/security/CustomRequestEntityConverterV2.java @@ -0,0 +1,91 @@ +package com.depromeet.global.security; + +import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_SECRET; + +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.infra.config.jwt.AppleProperties; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.io.IOException; +import java.io.StringReader; +import java.security.PrivateKey; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; +import org.springframework.util.MultiValueMap; + +@Slf4j +@RequiredArgsConstructor +public class CustomRequestEntityConverterV2 + extends OAuth2AuthorizationCodeGrantRequestEntityConverter { + + public static final int APPLE_CLIENT_SECRET_EXIPRE_DURATION = 30; + private final AppleProperties appleProperties; + + @Override + protected MultiValueMap createParameters( + OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + MultiValueMap parameters = + super.createParameters(authorizationCodeGrantRequest); + + if (isAppleRequest(authorizationCodeGrantRequest)) { + changeClientSecretParamToGeneratedValue(parameters); + } + + return parameters; + } + + private boolean isAppleRequest( + OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + String registrationId = + authorizationCodeGrantRequest.getClientRegistration().getRegistrationId(); + return registrationId.equals(appleProperties.clientName()); + } + + private void changeClientSecretParamToGeneratedValue(MultiValueMap parameters) { + String realClientSecret = generateClientSecret(); + parameters.set(CLIENT_SECRET, realClientSecret); + } + + public String generateClientSecret() { + LocalDateTime now = LocalDateTime.now(); + + Date expireAt = + Date.from( + now.plusDays(APPLE_CLIENT_SECRET_EXIPRE_DURATION) + .atZone(ZoneId.systemDefault()) + .toInstant()); + Date issuedAt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + + return Jwts.builder() + .setHeaderParam(JwsHeader.KEY_ID, appleProperties.keyId()) + .setIssuer(appleProperties.teamId()) + .setIssuedAt(issuedAt) + .setExpiration(expireAt) + .setAudience(appleProperties.audience()) + .setSubject(appleProperties.clientId()) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } + + public PrivateKey getPrivateKey() { + try { + StringReader reader = new StringReader(appleProperties.privateKey()); + PEMParser pemParser = new PEMParser(reader); + PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + return converter.getPrivateKey(object); + } catch (IOException e) { + throw new CustomException(ErrorCode.INVALID_APPLE_PRIVATE_KEY); + } + } +} diff --git a/src/main/java/com/depromeet/global/security/JwtAuthenticationFilter.java b/src/main/java/com/depromeet/global/security/JwtAuthenticationFilter.java new file mode 100644 index 000000000..34dbe929f --- /dev/null +++ b/src/main/java/com/depromeet/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.depromeet.global.security; + +import static com.depromeet.global.common.constants.SecurityConstants.*; + +import com.depromeet.domain.auth.application.JwtTokenService; +import com.depromeet.domain.auth.dto.response.AccessToken; +import com.depromeet.global.util.CookieUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenService jwtTokenService; + private final CookieUtil cookieUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String accessToken = extractAccessToken(request); + String refreshToken = extractRefreshToken(request); + + // ATK, RTK 둘 중 하나라도 빈 상태로 오면 통과 + if (accessToken == null || refreshToken == null) { + filterChain.doFilter(request, response); + return; + } + + boolean isAccessTokenExpired = jwtTokenService.isAccessTokenExpired(accessToken); + boolean isRefreshTokenExpired = jwtTokenService.isRefreshTokenExpired(refreshToken); + + // ATK, RTK 둘 다 만료되었으면 통과 + if (isAccessTokenExpired && isRefreshTokenExpired) { + filterChain.doFilter(request, response); + return; + } + + // ATK 만료되었고 RTK 만료되지 않았으면 RTK로 ATK, RTK 재발급 + if (isAccessTokenExpired && !isRefreshTokenExpired) { + accessToken = jwtTokenService.reissueAccessToken(refreshToken); + refreshToken = jwtTokenService.reissueRefreshToken(refreshToken); + } + + // ATK 만료되지 않았고 RTK 만료되었으면 ATK로 ATK, RTK 재발급 + if (!isAccessTokenExpired && isRefreshTokenExpired) { + AccessToken accessTokenDto = jwtTokenService.parseAccessToken(accessToken); + accessToken = jwtTokenService.reissueAccessToken(accessTokenDto); + refreshToken = jwtTokenService.reissueRefreshToken(accessTokenDto); + } + + // ATK, RTK 둘 다 만료되지 않았으면 RTK 재발급 + if (!isAccessTokenExpired && !isRefreshTokenExpired) { + AccessToken accessTokenDto = jwtTokenService.parseAccessToken(accessToken); + refreshToken = jwtTokenService.reissueRefreshToken(accessTokenDto); + } + + cookieUtil.addTokenCookies(response, accessToken, refreshToken); + Authentication authentication = jwtTokenService.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } + + private String extractRefreshToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(REFRESH_TOKEN_HEADER)) + .filter(token -> token.startsWith(TOKEN_PREFIX)) + .map(token -> token.replace(TOKEN_PREFIX, "")) + .orElse(null); + } + + private String extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(ACCESS_TOKEN_HEADER)) + .filter(token -> token.startsWith(TOKEN_PREFIX)) + .map(token -> token.replace(TOKEN_PREFIX, "")) + .orElse(null); + } +} diff --git a/src/main/java/com/depromeet/global/security/JwtTokenProvider.java b/src/main/java/com/depromeet/global/security/JwtTokenProvider.java new file mode 100644 index 000000000..9515f659c --- /dev/null +++ b/src/main/java/com/depromeet/global/security/JwtTokenProvider.java @@ -0,0 +1,147 @@ +package com.depromeet.global.security; + +import static com.depromeet.global.common.constants.SecurityConstants.TOKEN_ROLE_NAME; + +import com.depromeet.domain.auth.dto.response.AccessToken; +import com.depromeet.domain.member.domain.MemberRole; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.infra.config.jwt.JwtProperties; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + + public String generateAccessToken(Long memberId, MemberRole memberRole) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + return buildAccessToken(memberId, memberRole, issuedAt, expiredAt); + } + + public String generateRefreshToken(Long memberId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + return buildRefreshToken(memberId, issuedAt, expiredAt); + } + + public AccessToken parseAccessToken(String token) { + Jws claims = getClaims(token, getAccessTokenKey()); + + try { + validateDefaultClaims(claims); + return new AccessToken( + Long.parseLong(claims.getBody().getSubject()), + MemberRole.valueOf(claims.getBody().get(TOKEN_ROLE_NAME, String.class))); + } catch (Exception e) { + throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN); + } + } + + public Long parseRefreshToken(String token) { + Jws claims = getClaims(token, getRefreshTokenKey()); + + try { + validateDefaultClaims(claims); + return Long.parseLong(claims.getBody().getSubject()); // returns memberId + } catch (Exception e) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + } + + // TODO: ATK와 RTK의 유효성 검증을 한 번에 할 수 있도록 리팩토링 + // TODO: 인증 사이클 당 2회의 토큰 파싱이 발생하는 이슈 개선 + public boolean isAccessTokenExpired(String token) { + try { + parseAccessToken(token); + return false; + } catch (CustomException e) { + if (e.getErrorCode() == ErrorCode.EXPIRED_JWT_TOKEN) { + return true; + } + throw e; + } + } + + public boolean isRefreshTokenExpired(String token) { + try { + parseRefreshToken(token); + return false; + } catch (CustomException e) { + if (e.getErrorCode() == ErrorCode.EXPIRED_JWT_TOKEN) { + return true; + } + throw e; + } + } + + public long getRefreshTokenExpirationTime() { + return jwtProperties.refreshTokenExpirationTime(); + } + + private Key getRefreshTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.refreshTokenSecret().getBytes()); + } + + private Key getAccessTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + } + + private void validateDefaultClaims(Jws claims) { + // issuer가 일치하는지 검증 + if (jwtProperties.issuer().equals(claims.getBody().getIssuer())) { + return; + } + + // subject 문자열 리터럴이 양수 memberId인지 검증 + if (claims.getBody().getSubject().matches("^[1-9][0-9]*$")) { + return; + } + + // INVALID_JWT_TOKEN 예외는 각 parse 메서드에서 INVALID_ACCESS_TOKEN, INVALID_REFRESH_TOKEN 예외로 변환됨 + throw new CustomException(ErrorCode.INVALID_JWT_TOKEN); + } + + private Jws getClaims(String token, Key key) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.EXPIRED_JWT_TOKEN); + } catch (Exception e) { + throw new CustomException(ErrorCode.INVALID_JWT_TOKEN); + } + } + + private String buildAccessToken( + Long memberId, MemberRole memberRole, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(memberId.toString()) + .claim(TOKEN_ROLE_NAME, memberRole.name()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getAccessTokenKey()) + .compact(); + } + + private String buildRefreshToken(Long memberId, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(memberId.toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getRefreshTokenKey()) + .compact(); + } +} diff --git a/src/main/java/com/depromeet/global/security/PrincipalDetails.java b/src/main/java/com/depromeet/global/security/PrincipalDetails.java new file mode 100644 index 000000000..c1f97f902 --- /dev/null +++ b/src/main/java/com/depromeet/global/security/PrincipalDetails.java @@ -0,0 +1,50 @@ +package com.depromeet.global.security; + +import java.util.Collection; +import java.util.Collections; +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@AllArgsConstructor +public class PrincipalDetails implements UserDetails { + + private final Long memberId; + private final String role; + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + role)); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return memberId.toString(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/depromeet/global/util/CookieUtil.java b/src/main/java/com/depromeet/global/util/CookieUtil.java new file mode 100644 index 000000000..adeaf9283 --- /dev/null +++ b/src/main/java/com/depromeet/global/util/CookieUtil.java @@ -0,0 +1,58 @@ +package com.depromeet.global.util; + +import com.depromeet.infra.config.jwt.JwtProperties; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + + private final SpringEnvironmentUtil springEnvironmentUtil; + private final JwtProperties jwtProperties; + + public void addTokenCookies( + HttpServletResponse response, String accessToken, String refreshToken) { + HttpHeaders headers = generateTokenCookies(accessToken, refreshToken); + headers.forEach((key, value) -> response.addHeader(key, value.get(0))); + } + + public HttpHeaders generateTokenCookies(String accessToken, String refreshToken) { + + String sameSite = determineSameSitePolicy(); + + ResponseCookie accessTokenCookie = + ResponseCookie.from("accessToken", accessToken) + .path("/") + .maxAge(jwtProperties.accessTokenExpirationTime()) + .secure(true) + .sameSite(sameSite) + .httpOnly(false) + .build(); + + ResponseCookie refreshTokenCookie = + ResponseCookie.from("refreshToken", refreshToken) + .path("/") + .maxAge(jwtProperties.refreshTokenExpirationTime()) + .secure(true) + .sameSite(sameSite) + .httpOnly(false) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return headers; + } + + private String determineSameSitePolicy() { + if (springEnvironmentUtil.isProdProfile()) { + return "Strict"; + } + return "None"; + } +} diff --git a/src/main/java/com/depromeet/global/util/MemberUtil.java b/src/main/java/com/depromeet/global/util/MemberUtil.java new file mode 100644 index 000000000..0fcb0b776 --- /dev/null +++ b/src/main/java/com/depromeet/global/util/MemberUtil.java @@ -0,0 +1,22 @@ +package com.depromeet.global.util; + +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberUtil { + + private final SecurityUtil securityUtil; + private final MemberRepository memberRepository; + + public Member getCurrentMember() { + return memberRepository + .findById(securityUtil.getCurrentMemberId()) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/depromeet/global/util/SecurityUtil.java b/src/main/java/com/depromeet/global/util/SecurityUtil.java new file mode 100644 index 000000000..2eee4c682 --- /dev/null +++ b/src/main/java/com/depromeet/global/util/SecurityUtil.java @@ -0,0 +1,16 @@ +package com.depromeet.global.util; + +import com.depromeet.global.security.PrincipalDetails; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityUtil { + + public Long getCurrentMemberId() { + PrincipalDetails principal = + (PrincipalDetails) + SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return Long.parseLong(principal.getUsername()); + } +} diff --git a/src/main/java/com/depromeet/global/util/SpringEnvironmentUtil.java b/src/main/java/com/depromeet/global/util/SpringEnvironmentUtil.java new file mode 100644 index 000000000..9b7278e8c --- /dev/null +++ b/src/main/java/com/depromeet/global/util/SpringEnvironmentUtil.java @@ -0,0 +1,44 @@ +package com.depromeet.global.util; + +import com.depromeet.global.common.constants.EnvironmentConstants; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpringEnvironmentUtil { + private final Environment environment; + + private final List PROD_AND_DEV = + List.of(EnvironmentConstants.PROD.getValue(), EnvironmentConstants.DEV.getValue()); + + public String getCurrentProfile() { + return getActiveProfiles() + .filter( + profile -> + profile.equals(EnvironmentConstants.PROD.getValue()) + || profile.equals(EnvironmentConstants.DEV.getValue())) + .findFirst() + .orElse(EnvironmentConstants.LOCAL.getValue()); + } + + public Boolean isProdProfile() { + return getActiveProfiles().anyMatch(EnvironmentConstants.PROD.getValue()::equals); + } + + public Boolean isDevProfile() { + return getActiveProfiles().anyMatch(EnvironmentConstants.DEV.getValue()::equals); + } + + public Boolean isProdAndDevProfile() { + return getActiveProfiles().anyMatch(PROD_AND_DEV::contains); + } + + private Stream getActiveProfiles() { + return Arrays.stream(environment.getActiveProfiles()); + } +} diff --git a/src/main/java/com/depromeet/infra/config/jpa/JpaConfig.java b/src/main/java/com/depromeet/infra/config/jpa/JpaConfig.java new file mode 100644 index 000000000..b10428910 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/jpa/JpaConfig.java @@ -0,0 +1,8 @@ +package com.depromeet.infra.config.jpa; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig {} diff --git a/src/main/java/com/depromeet/infra/config/jwt/AppleProperties.java b/src/main/java/com/depromeet/infra/config/jwt/AppleProperties.java new file mode 100644 index 000000000..9f72d1c9a --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/jwt/AppleProperties.java @@ -0,0 +1,30 @@ +package com.depromeet.infra.config.jwt; + +import com.depromeet.global.security.CustomRequestEntityConverter; + +// @ConfigurationProperties(prefix = "spring.security.oauth2.client.registration.apple") +public record AppleProperties(String clientId, String clientSecret, String clientName) { + /** + * clientSecret 필드에는 실제 clientSecret이 들어있지 않음. 따라서 appleProperties.clientSecret()를 호출하면 안됨. + * + *

대신 clientSecret 생성에 필요한 private key, key id, team id가 순서대로들어있음 각 프로퍼티를 '|'로 구분하여 파싱하여 + * 사용함. @See {@link CustomRequestEntityConverter#convert} + */ + private static final String APPLE_URL = "https://appleid.apple.com"; + + public String privateKey() { + return clientSecret.split("\\|")[0]; + } + + public String keyId() { + return clientSecret.split("\\|")[1]; + } + + public String teamId() { + return clientSecret.split("\\|")[2]; + } + + public String audience() { + return APPLE_URL; + } +} diff --git a/src/main/java/com/depromeet/infra/config/jwt/JwtProperties.java b/src/main/java/com/depromeet/infra/config/jwt/JwtProperties.java new file mode 100644 index 000000000..c6c088a42 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/jwt/JwtProperties.java @@ -0,0 +1,20 @@ +package com.depromeet.infra.config.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String accessTokenSecret, + String refreshTokenSecret, + Long accessTokenExpirationTime, + Long refreshTokenExpirationTime, + String issuer) { + + public Long accessTokenExpirationMilliTime() { + return accessTokenExpirationTime * 1000; + } + + public Long refreshTokenExpirationMilliTime() { + return refreshTokenExpirationTime * 1000; + } +} diff --git a/src/main/java/com/depromeet/infra/config/properties/PropertiesConfig.java b/src/main/java/com/depromeet/infra/config/properties/PropertiesConfig.java new file mode 100644 index 000000000..f27dc3d73 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/properties/PropertiesConfig.java @@ -0,0 +1,16 @@ +package com.depromeet.infra.config.properties; + +import com.depromeet.infra.config.jwt.JwtProperties; +import com.depromeet.infra.config.redis.RedisProperties; +import com.depromeet.infra.config.storage.StorageProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties({ + StorageProperties.class, + RedisProperties.class, + JwtProperties.class, + // AppleProperties.class +}) +@Configuration +public class PropertiesConfig {} diff --git a/src/main/java/com/depromeet/infra/config/redis/RedisConfig.java b/src/main/java/com/depromeet/infra/config/redis/RedisConfig.java new file mode 100644 index 000000000..633294886 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/redis/RedisConfig.java @@ -0,0 +1,34 @@ +package com.depromeet.infra.config.redis; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisKeyValueAdapter; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +@EnableRedisRepositories( + enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = + new RedisStandaloneConfiguration(redisProperties.host(), redisProperties.port()); + if (!redisProperties.password().isBlank()) + redisConfig.setPassword(redisProperties.password()); + LettuceClientConfiguration clientConfig = + LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(1)) + .shutdownTimeout(Duration.ZERO) + .build(); + return new LettuceConnectionFactory(redisConfig, clientConfig); + } +} diff --git a/src/main/java/com/depromeet/infra/config/redis/RedisMessageListenerConfig.java b/src/main/java/com/depromeet/infra/config/redis/RedisMessageListenerConfig.java new file mode 100644 index 000000000..f3318e654 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/redis/RedisMessageListenerConfig.java @@ -0,0 +1,24 @@ +package com.depromeet.infra.config.redis; + +import com.depromeet.domain.missionRecord.application.RedisExpireEventRedisMessageListener; +import com.depromeet.global.common.constants.RedisExpireEventConstants; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +@Configuration +public class RedisMessageListenerConfig { + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory redisConnectionFactory, + RedisExpireEventRedisMessageListener redisExpireEventRedisMessageListener) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory); + container.addMessageListener( + redisExpireEventRedisMessageListener, + new PatternTopic(RedisExpireEventConstants.REDIS_EXPIRE_EVENT_PATTERN.getValue())); + return container; + } +} diff --git a/src/main/java/com/depromeet/infra/config/redis/RedisProperties.java b/src/main/java/com/depromeet/infra/config/redis/RedisProperties.java new file mode 100644 index 000000000..007062316 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/redis/RedisProperties.java @@ -0,0 +1,6 @@ +package com.depromeet.infra.config.redis; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.data.redis") +public record RedisProperties(String host, int port, String password) {} diff --git a/src/main/java/com/depromeet/infra/config/storage/StorageConfig.java b/src/main/java/com/depromeet/infra/config/storage/StorageConfig.java new file mode 100644 index 000000000..29b549694 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/storage/StorageConfig.java @@ -0,0 +1,32 @@ +package com.depromeet.infra.config.storage; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class StorageConfig { + private final StorageProperties storageProperties; + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials credentials = + new BasicAWSCredentials( + storageProperties.accessKey(), storageProperties.secretKey()); + AwsClientBuilder.EndpointConfiguration endpointConfiguration = + new AwsClientBuilder.EndpointConfiguration( + storageProperties.endpoint(), storageProperties.region()); + + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(endpointConfiguration) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/com/depromeet/infra/config/storage/StorageProperties.java b/src/main/java/com/depromeet/infra/config/storage/StorageProperties.java new file mode 100644 index 000000000..229e6fa82 --- /dev/null +++ b/src/main/java/com/depromeet/infra/config/storage/StorageProperties.java @@ -0,0 +1,7 @@ +package com.depromeet.infra.config.storage; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "storage") +public record StorageProperties( + String accessKey, String secretKey, String region, String bucket, String endpoint) {} diff --git a/src/main/resources/application-actuator.yml b/src/main/resources/application-actuator.yml new file mode 100644 index 000000000..7dd50e51c --- /dev/null +++ b/src/main/resources/application-actuator.yml @@ -0,0 +1,17 @@ +spring: + config: + activate: + on-profile: "actuator" +management: + endpoints: + web: + exposure: + include: health + base-path: /10mm-actuator + jmx: + exposure: + exclude: "*" + enabled-by-default: false + endpoint: + health: + enabled: true diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml new file mode 100644 index 000000000..b3a224a21 --- /dev/null +++ b/src/main/resources/application-datasource.yml @@ -0,0 +1,16 @@ +spring: + config: + activate: + on-profile: "datasource" + datasource: + url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true&tinyInt1isBit=false + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maxLifetime: 580000 + maximum-pool-size: 20 + password: ${MYSQL_PASSWORD} + username: ${MYSQL_USERNAME} + jpa: + properties: + hibernate: + default_batch_fetch_size: ${JPA_BATCH_FETCH_SIZE:100} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 000000000..ccd90cc64 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,12 @@ +spring: + config: + activate: + on-profile: "dev" + jpa: + hibernate: + ddl-auto: update + +logging: + level: + org.springframework.orm.jpa: DEBUG + org.springframework.transaction: DEBUG diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 000000000..290978098 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,17 @@ +spring: + config: + activate: + on-profile: "local" + jpa: + hibernate: + ddl-auto: create + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + format_sql: ${FORMAT_SQL:true} + defer-datasource-initialization: true + open-in-view: false +logging: + level: + org.springframework.orm.jpa: DEBUG + org.springframework.transaction: DEBUG diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 000000000..5564164c7 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,7 @@ +spring: + config: + activate: + on-profile: "prod" + jpa: + hibernate: + ddl-auto: none diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml new file mode 100644 index 000000000..0f5ac38c0 --- /dev/null +++ b/src/main/resources/application-redis.yml @@ -0,0 +1,9 @@ +spring: + config: + activate: + on-profile: "redis" + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} diff --git a/src/main/resources/application-security.yml b/src/main/resources/application-security.yml new file mode 100644 index 000000000..5a1cc4b59 --- /dev/null +++ b/src/main/resources/application-security.yml @@ -0,0 +1,44 @@ +spring: + security: + oauth2: + client: + registration: + kakao: + authorization-grant-type: authorization_code + client-id: ${KAKAO_CLIENT_ID:default} + client-secret: ${KAKAO_CLIENT_SECRET:default} + redirect-uri: ${KAKAO_REDIRECT_URI:default} + client-name: Kakao + client-authentication-method: client_secret_post + scope: + - openid + - account_email + apple: + authorization-grant-type: authorization_code + client-id: ${APPLE_CLIENT_ID:default} + client-secret: ${APPLE_PRIVATE_KEY:default}|${APPLE_KEY_ID:default}|${APPLE_TEAM_ID:default} + redirect-uri: ${APPLE_REDIRECT_URI:default} + client-name: apple + scope: + - openid + - name + - email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v1/oidc/userinfo + user-name-attribute: sub + jwk-set-uri: https://kauth.kakao.com/.well-known/jwks.json + apple: + authorization-uri: https://appleid.apple.com/auth/authorize?response_mode=form_post + token-uri: https://appleid.apple.com/auth/token + user-name-attribute: sub + jwk-set-uri: https://appleid.apple.com/auth/keys + +jwt: + access-token-secret: ${JWT_ACCESS_TOKEN_SECRET:} + refresh-token-secret: ${JWT_REFRESH_TOKEN_SECRET:} + access-token-expiration-time: ${JWT_ACCESS_TOKEN_EXPIRATION_TIME:7200} + refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800} + issuer: ${JWT_ISSUER:} diff --git a/src/main/resources/application-storage.yml b/src/main/resources/application-storage.yml new file mode 100644 index 000000000..eb57092c4 --- /dev/null +++ b/src/main/resources/application-storage.yml @@ -0,0 +1,10 @@ +spring: + config: + activate: + on-profile: "storage" +storage: + accessKey: ${STORAGE_ACCESS_KEY:} + secretKey: ${STORAGE_SECRET_KEY:} + bucket: ${STORAGE_BUCKET:} + region: ${STORAGE_REGION:} + endpoint: ${STORAGE_ENDPOINT:https://kr.object.ncloudstorage.com} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b1378917..000000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..13a9b0342 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,32 @@ +spring: + profiles: + group: + test: "test" + local: "local, datasource" + dev: "dev, datasource, actuator" + prod: "prod, datasource, actuator" + include: + - redis + - storage + - security + +swagger: + version: 0.0.1 + user: ${SWAGGER_USER:default} + password: ${SWAGGER_PASSWORD:default} +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + path: /swagger-ui + disable-swagger-default-url: true + display-request-duration: true + tags-sorter: alpha + operations-sorter: alpha + syntax-highlight: + theme: none + urls-primary-name: 10MM API DOCS + +logging: + level: + com.depromeet.domain.*.api.*: debug diff --git a/src/test/java/com/depromeet/DatabaseCleaner.java b/src/test/java/com/depromeet/DatabaseCleaner.java new file mode 100644 index 000000000..8a87909d4 --- /dev/null +++ b/src/test/java/com/depromeet/DatabaseCleaner.java @@ -0,0 +1,52 @@ +package com.depromeet; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.stream.Collectors; +import org.hibernate.Session; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +@Component +public class DatabaseCleaner implements InitializingBean { + + @PersistenceContext private EntityManager entityManager; + private List tableNames; + + @Override + public void afterPropertiesSet() { + entityManager.unwrap(Session.class).doWork(this::extractTableNames); + } + + private void extractTableNames(Connection conn) { + tableNames = + entityManager.getMetamodel().getEntities().stream() + .map(e -> e.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase()) + .collect(Collectors.toList()); + } + + public void execute() { + entityManager.unwrap(Session.class).doWork(this::cleanUpDatabase); + } + + private void cleanUpDatabase(Connection conn) throws SQLException { + Statement statement = conn.createStatement(); + statement.executeUpdate("SET REFERENTIAL_INTEGRITY FALSE"); + + for (String tableName : tableNames) { + statement.executeUpdate("TRUNCATE TABLE " + tableName); + statement.executeUpdate( + "ALTER TABLE " + + tableName + + " ALTER COLUMN " + + tableName + + "_id RESTART WITH 1"); + } + + statement.executeUpdate("SET REFERENTIAL_INTEGRITY TRUE"); + } +} diff --git a/src/test/java/com/depromeet/TenMinuteApplicationTests.java b/src/test/java/com/depromeet/TenMinuteApplicationTests.java deleted file mode 100644 index c5e2d7bc5..000000000 --- a/src/test/java/com/depromeet/TenMinuteApplicationTests.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.depromeet; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class TenMinuteApplicationTests { - @Test - void contextLoads() { - } -} diff --git a/src/test/java/com/depromeet/TenminuteApplicationTests.java b/src/test/java/com/depromeet/TenminuteApplicationTests.java new file mode 100644 index 000000000..4da0c8de5 --- /dev/null +++ b/src/test/java/com/depromeet/TenminuteApplicationTests.java @@ -0,0 +1,12 @@ +package com.depromeet; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class TenminuteApplicationTests { + @Test + void contextLoads() {} +} diff --git a/src/test/java/com/depromeet/TestQuerydslConfig.java b/src/test/java/com/depromeet/TestQuerydslConfig.java new file mode 100644 index 000000000..61e92e51d --- /dev/null +++ b/src/test/java/com/depromeet/TestQuerydslConfig.java @@ -0,0 +1,17 @@ +package com.depromeet; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestQuerydslConfig { + @PersistenceContext public EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/test/java/com/depromeet/TestRedisConfig.java b/src/test/java/com/depromeet/TestRedisConfig.java new file mode 100644 index 000000000..b0aea8eb1 --- /dev/null +++ b/src/test/java/com/depromeet/TestRedisConfig.java @@ -0,0 +1,12 @@ +package com.depromeet; + +import com.depromeet.infra.config.redis.RedisConfig; +import com.depromeet.infra.config.redis.RedisProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; + +@TestConfiguration +@EnableConfigurationProperties({RedisProperties.class}) +@Import({RedisConfig.class}) +public class TestRedisConfig {} diff --git a/src/test/java/com/depromeet/domain/auth/dao/RefreshTokenRepositoryTest.java b/src/test/java/com/depromeet/domain/auth/dao/RefreshTokenRepositoryTest.java new file mode 100644 index 000000000..361b97885 --- /dev/null +++ b/src/test/java/com/depromeet/domain/auth/dao/RefreshTokenRepositoryTest.java @@ -0,0 +1,70 @@ +package com.depromeet.domain.auth.dao; + +import static org.junit.jupiter.api.Assertions.*; + +import com.depromeet.TestRedisConfig; +import com.depromeet.domain.auth.domain.RefreshToken; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +@DataRedisTest +class RefreshTokenRepositoryTest { + + @Autowired private RefreshTokenRepository refreshTokenRepository; + + @AfterEach + public void tearDown() { + refreshTokenRepository.deleteAll(); + } + + @Test + void 리프레시_토큰을_저장한다() { + // given + RefreshToken refreshToken = + RefreshToken.builder().memberId(1L).token("testRefreshToken").ttl(1000).build(); + + // when + refreshTokenRepository.save(refreshToken); + + // then + Optional savedRefreshToken = refreshTokenRepository.findById(1L); + assertTrue(savedRefreshToken.isPresent()); + } + + @Test + void 리프레시_토큰을_삭제한다() { + // given + RefreshToken refreshToken = + RefreshToken.builder().memberId(2L).token("testRefreshToken").ttl(1000).build(); + refreshTokenRepository.save(refreshToken); + + // when + refreshTokenRepository.delete(refreshToken); + + // then + assertFalse(refreshTokenRepository.findById(refreshToken.getMemberId()).isPresent()); + } + + @Test + void 리프레시_토큰을_조회한다() { + // given + RefreshToken refreshToken = + RefreshToken.builder().memberId(3L).token("testRefreshToken").ttl(1000).build(); + refreshTokenRepository.save(refreshToken); + + // when + Optional savedRefreshToken = refreshTokenRepository.findById(3L); + + // then + assertTrue(savedRefreshToken.isPresent()); + assertEquals(3L, savedRefreshToken.get().getMemberId()); + assertEquals("testRefreshToken", savedRefreshToken.get().getToken()); + } +} diff --git a/src/test/java/com/depromeet/domain/image/api/ImageControllerTest.java b/src/test/java/com/depromeet/domain/image/api/ImageControllerTest.java new file mode 100644 index 000000000..0f03fa4cb --- /dev/null +++ b/src/test/java/com/depromeet/domain/image/api/ImageControllerTest.java @@ -0,0 +1,182 @@ +package com.depromeet.domain.image.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.depromeet.domain.image.application.ImageService; +import com.depromeet.domain.image.domain.ImageFileExtension; +import com.depromeet.domain.image.dto.request.MissionRecordImageCreateRequest; +import com.depromeet.domain.image.dto.request.MissionRecordImageUploadCompleteRequest; +import com.depromeet.domain.image.dto.response.PresignedUrlResponse; +import com.depromeet.global.security.JwtAuthenticationFilter; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.awt.*; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ImageController.class) +@AutoConfigureMockMvc(addFilters = false) +@MockBean({JpaMetamodelMappingContext.class, JwtAuthenticationFilter.class}) +class ImageControllerTest { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private ImageService imageService; + + @Nested + class 미션_기록_이미지_PresignedUrl을_생성할_때 { + @Test + void 미션_ID가_NULL_이라면_예외를_발생시킨다() throws Exception { + // given + MissionRecordImageCreateRequest request = + new MissionRecordImageCreateRequest(null, ImageFileExtension.JPEG); + + // when, then + mockMvc.perform( + post("/records/upload-url") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect( + jsonPath("$.data.errorClassName") + .value("MethodArgumentNotValidException")) + .andExpect(jsonPath("$.data.message").value("미션 기록 ID는 비워둘 수 없습니다.")) + .andDo(print()); + } + + @Test + void 이미지_파일의_확장자가_NULL_이라면_예외를_발생시킨다() throws Exception { + // given + MissionRecordImageCreateRequest request = new MissionRecordImageCreateRequest(1L, null); + + // when, then + mockMvc.perform( + post("/records/upload-url") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect( + jsonPath("$.data.errorClassName") + .value("MethodArgumentNotValidException")) + .andExpect(jsonPath("$.data.message").value("이미지 파일의 확장자는 비워둘 수 없습니다.")) + .andDo(print()); + } + + @Test + void 입력_값이_정상이라면_예외가_발생하지_않는다() throws Exception { + // given + MissionRecordImageCreateRequest request = + new MissionRecordImageCreateRequest(30L, ImageFileExtension.JPEG); + PresignedUrlResponse response = PresignedUrlResponse.from("presignedUrl"); + + // when + given(imageService.createMissionRecordPresignedUrl(request)).willReturn(response); + + // then + mockMvc.perform( + post("/records/upload-url") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.presignedUrl").value("presignedUrl")) + .andDo(print()); + } + } + + @Nested + class 미션_기록_이미지_업로드_완료_처리할_때 { + @Test + void 미션_ID가_NULL_이라면_예외를_발생시킨다() throws Exception { + // given + MissionRecordImageUploadCompleteRequest request = + new MissionRecordImageUploadCompleteRequest( + null, ImageFileExtension.JPEG, "미션 일지"); + + // when, then + mockMvc.perform( + post("/records/upload-complete") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect( + jsonPath("$.data.errorClassName") + .value("MethodArgumentNotValidException")) + .andExpect(jsonPath("$.data.message").value("미션 기록 ID는 비워둘 수 없습니다.")) + .andDo(print()); + } + + @Test + void 이미지_파일의_확장자가_NULL_이라면_예외를_발생시킨다() throws Exception { + // given + MissionRecordImageUploadCompleteRequest request = + new MissionRecordImageUploadCompleteRequest(182L, null, "미션 일지"); + + // when, then + mockMvc.perform( + post("/records/upload-complete") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect( + jsonPath("$.data.errorClassName") + .value("MethodArgumentNotValidException")) + .andExpect(jsonPath("$.data.message").value("이미지 파일의 확장자는 비워둘 수 없습니다.")) + .andDo(print()); + } + + @Test + void 미션_일지는_NULL_이라도_예외가_발생하지_않는다() throws Exception { + MissionRecordImageUploadCompleteRequest request = + new MissionRecordImageUploadCompleteRequest( + 182L, ImageFileExtension.JPEG, null); + + // when, then + mockMvc.perform( + post("/records/upload-complete") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andDo(print()); + } + + @Test + void 입력_값이_정상이라면_예외가_발생하지_않는다() throws Exception { + // given + MissionRecordImageUploadCompleteRequest request = + new MissionRecordImageUploadCompleteRequest( + 182L, ImageFileExtension.JPEG, "미션 일지"); + + // when, then + mockMvc.perform( + post("/records/upload-url") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andDo(print()); + } + } +} diff --git a/src/test/java/com/depromeet/domain/image/application/ImageServiceTest.java b/src/test/java/com/depromeet/domain/image/application/ImageServiceTest.java new file mode 100644 index 000000000..a27f87dc1 --- /dev/null +++ b/src/test/java/com/depromeet/domain/image/application/ImageServiceTest.java @@ -0,0 +1,308 @@ +package com.depromeet.domain.image.application; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import com.depromeet.DatabaseCleaner; +import com.depromeet.domain.image.domain.ImageFileExtension; +import com.depromeet.domain.image.dto.request.MissionRecordImageCreateRequest; +import com.depromeet.domain.image.dto.request.MissionRecordImageUploadCompleteRequest; +import com.depromeet.domain.image.dto.response.PresignedUrlResponse; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import com.depromeet.domain.mission.application.MissionService; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.domain.missionRecord.application.MissionRecordService; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.domain.ImageUploadStatus; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.domain.missionRecord.dto.response.MissionRecordCreateResponse; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.security.PrincipalDetails; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class ImageServiceTest { + @Autowired private DatabaseCleaner databaseCleaner; + @Autowired private MemberRepository memberRepository; + @Autowired private ImageService imageService; + @Autowired private MissionRecordService missionRecordService; + @Autowired private MissionService missionService; + @Autowired private MissionRecordRepository missionRecordRepository; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + PrincipalDetails principal = new PrincipalDetails(1L, "USER"); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, "password", principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @Nested + class 미션_기록_이미지_PresignedUrl을_생성할_때 { + // TODO: MemberUtil insertMockMemberIfNotExist메서드 제거 후 주석해제 예정 + // @Test + // void 회원이_존재하지_않는다면_예외를_발생시킨다() { + // // given + // MissionRecordImageCreateRequest request = + // new MissionRecordImageCreateRequest(192L, ImageFileExtension.JPEG); + // + // // when, then + // assertThatThrownBy(() -> imageService.createMissionRecordPresignedUrl(request)) + // .isInstanceOf(CustomException.class) + // .hasMessage(ErrorCode.MEMBER_NOT_FOUND.getMessage()); + // } + + @Test + void 미션이_존재하지_않는다면_예외를_발생시킨다() { + // given + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname", "testImageUrl"))); + MissionRecordImageCreateRequest request = + new MissionRecordImageCreateRequest(192L, ImageFileExtension.JPEG); + + // when, then + assertThatThrownBy(() -> imageService.createMissionRecordPresignedUrl(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.MISSION_RECORD_NOT_FOUND.getMessage()); + } + + // TODO: SecurityUtil setMockAuthentication메서드 제거 후 주석해제 예정 + // @Test + // void 미션을_생성한_유저와_로그인_유저가_일치하지_않는다면_예외를_발생시킨다() { + // // given + // memberRepository.save( + // Member.createNormalMember(new Profile("testNickname", + // "testImageUrl"))); + // MissionCreateRequest missionCreateRequest = + // new MissionCreateRequest( + // "testMissionName", + // "testMissionContent", + // MissionCategory.STUDY, + // MissionVisibility.ALL); + // MissionCreateResponse missionCreateResponse = + // missionService.createMission(missionCreateRequest); + // + // SecurityContextHolder.clearContext(); + // PrincipalDetails principal = new PrincipalDetails(2L, "USER"); + // Authentication authentication = + // new UsernamePasswordAuthenticationToken( + // principal, "password", principal.getAuthorities()); + // SecurityContextHolder.getContext().setAuthentication(authentication); + // + // + // LocalDateTime missionRecordStartedAt = LocalDateTime.of(2023, 12, 15, 1, 5, + // 0); + // LocalDateTime missionRecordFinishedAt = + // missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + // MissionRecordCreateRequest missionRecordCreateRequest = + // new MissionRecordCreateRequest( + // missionCreateResponse.missionId(), + // missionRecordStartedAt, + // missionRecordFinishedAt, + // 32, + // 14); + // Long missionRecord = + // missionRecordService.createMissionRecord(missionRecordCreateRequest); + // MissionRecordImageCreateRequest request = + // new MissionRecordImageCreateRequest(missionRecord, + // ImageFileExtension.JPEG); + // + // // when, then + // assertThatThrownBy(() -> + // imageService.createMissionRecordPresignedUrl(request)) + // .isInstanceOf(CustomException.class) + // .hasMessage(ErrorCode.MISSION_RECORD_USER_MISMATCH.getMessage()); + // } + + @Test + void 입력_값이_정상이라면_예외가_발생하지_않는다() { + // given + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname", "testImageUrl"))); + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse missionCreateResponse = + missionService.createMission(missionCreateRequest); + + LocalDateTime missionRecordStartedAt = LocalDateTime.of(2023, 12, 15, 1, 5, 0); + LocalDateTime missionRecordFinishedAt = + missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + MissionRecordCreateRequest missionRecordCreateRequest = + new MissionRecordCreateRequest( + missionCreateResponse.missionId(), + missionRecordStartedAt, + missionRecordFinishedAt, + 32, + 14); + + MissionRecordCreateResponse missionRecordCreateResponse = + missionRecordService.createMissionRecord(missionRecordCreateRequest); + MissionRecordImageCreateRequest request = + new MissionRecordImageCreateRequest( + missionRecordCreateResponse.missionId(), ImageFileExtension.JPEG); + + // when, then + assertThatCode(() -> imageService.createMissionRecordPresignedUrl(request)) + .doesNotThrowAnyException(); + } + + @Test + void 입력_값이_정상이라면_PresignedUrl이_정상적으로_생성된다() { + // given + Member member = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname", "testImageUrl"))); + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse missionCreateResponse = + missionService.createMission(missionCreateRequest); + + LocalDateTime missionRecordStartedAt = LocalDateTime.of(2023, 12, 15, 1, 5, 0); + LocalDateTime missionRecordFinishedAt = + missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + MissionRecordCreateRequest missionRecordCreateRequest = + new MissionRecordCreateRequest( + missionCreateResponse.missionId(), + missionRecordStartedAt, + missionRecordFinishedAt, + 32, + 14); + MissionRecordCreateResponse missionRecordCreateResponse = + missionRecordService.createMissionRecord(missionRecordCreateRequest); + ImageFileExtension imageFileExtension = ImageFileExtension.JPEG; + MissionRecordImageCreateRequest request = + new MissionRecordImageCreateRequest( + missionRecordCreateResponse.missionId(), imageFileExtension); + + // when + PresignedUrlResponse missionRecordPresignedUrl = + imageService.createMissionRecordPresignedUrl(request); + + // then + assertThat(missionRecordPresignedUrl.presignedUrl()) + .contains( + String.format( + "/local/mission_record/%s/image.jpeg", + missionRecordCreateResponse.missionId())); + } + } + + @Nested + class 미션_기록_이미지_업로드_완료_처리할_때 { + + // TODO: MemberUtil insertMockMemberIfNotExist메서드 제거 후 주석해제 예정 + // @Test + // void 회원이_존재하지_않는다면_예외를_발생시킨다() { + // // given + // MissionRecordImageUploadCompleteRequest request = + // new MissionRecordImageUploadCompleteRequest(192L, ImageFileExtension.JPEG, + // "testRemark"); + // + // // when, then + // assertThatThrownBy(() -> imageService.uploadCompleteMissionRecord(request)) + // .isInstanceOf(CustomException.class) + // .hasMessage(ErrorCode.MEMBER_NOT_FOUND.getMessage()); + // } + + @Test + void 미션이_존재하지_않는다면_예외를_발생시킨다() { + // given + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname", "testImageUrl"))); + MissionRecordImageUploadCompleteRequest request = + new MissionRecordImageUploadCompleteRequest( + 192L, ImageFileExtension.JPEG, "testRemark"); + + // when, then + assertThatThrownBy(() -> imageService.uploadCompleteMissionRecord(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.MISSION_RECORD_NOT_FOUND.getMessage()); + } + + @Test + void 입력_값이_정상이라면_미션_이미지_업로드_완료처리가_된다() { + // given + Member member = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname", "testImageUrl"))); + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse missionCreateResponse = + missionService.createMission(missionCreateRequest); + + LocalDateTime missionRecordStartedAt = LocalDateTime.of(2023, 12, 15, 1, 5, 0); + LocalDateTime missionRecordFinishedAt = + missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + MissionRecordCreateRequest missionRecordCreateRequest = + new MissionRecordCreateRequest( + missionCreateResponse.missionId(), + missionRecordStartedAt, + missionRecordFinishedAt, + 32, + 14); + MissionRecordCreateResponse missionRecordCreateResponse = + missionRecordService.createMissionRecord(missionRecordCreateRequest); + + ImageFileExtension imageFileExtension = ImageFileExtension.JPEG; + MissionRecordImageCreateRequest missionRecordImageCreateRequest = + new MissionRecordImageCreateRequest( + missionRecordCreateResponse.missionId(), imageFileExtension); + imageService.createMissionRecordPresignedUrl(missionRecordImageCreateRequest); + + MissionRecordImageUploadCompleteRequest request = + new MissionRecordImageUploadCompleteRequest( + missionRecordCreateResponse.missionId(), + imageFileExtension, + "testRemark"); + + // when + imageService.uploadCompleteMissionRecord(request); + MissionRecord missionRecord = + missionRecordRepository.findById(missionRecordCreateResponse.missionId()).get(); + + // then + assertThat(missionRecord.getUploadStatus()).isEqualTo(ImageUploadStatus.COMPLETE); + assertThat(missionRecord.getRemark()).isEqualTo("testRemark"); + assertThat(missionRecord.getImageUrl()) + .contains( + String.format( + "/local/mission_record/%s/image.jpeg", + missionRecordCreateResponse.missionId())); + } + } +} diff --git a/src/test/java/com/depromeet/domain/member/domain/MemberTest.java b/src/test/java/com/depromeet/domain/member/domain/MemberTest.java new file mode 100644 index 000000000..f18da86d0 --- /dev/null +++ b/src/test/java/com/depromeet/domain/member/domain/MemberTest.java @@ -0,0 +1,101 @@ +package com.depromeet.domain.member.domain; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MemberTest { + + // Fixture + Profile profile; + + @BeforeEach + void setUp() { + profile = Profile.createProfile("testNickname", "testProfileImageUrl"); + } + + @Test + void 회원가입시_초기_상태는_NORMAL이다() { + // given + Member member = + Member.createGuestMember( + OauthInfo.createOauthInfo("testProvider", "testProviderId")); + + // when + MemberStatus status = member.getStatus(); + + // then + assertEquals(MemberStatus.NORMAL, status); + } + + @Test + void 회원가입시_초기_역할은_GUEST이다() { + // given + Member member = + Member.createGuestMember( + OauthInfo.createOauthInfo("testProvider", "testProviderId")); + + // when + MemberRole role = member.getRole(); + + // then + assertEquals(MemberRole.GUEST, role); + } + + @Test + void 회원가입시_초기_공개여부는_PUBLIC이다() { + // given + Member member = + Member.createGuestMember( + OauthInfo.createOauthInfo("testProvider", "testProviderId")); + + // when + MemberVisibility visibility = member.getVisibility(); + + // then + assertEquals(MemberVisibility.PUBLIC, visibility); + } + + @Test + void 회원가입시_게스트멤버의_닉네임이_설정된다() { + // given + Member member = + Member.createGuestMember( + OauthInfo.createOauthInfo("testProvider", "testProviderId")); + + // when + member.register("testNickname"); + + // then + assertEquals("testNickname", member.getProfile().getNickname()); + } + + @Test + void 회원가입시_게스트멤버는_일반멤버로_변경된다() { + // given + Member member = + Member.createGuestMember( + OauthInfo.createOauthInfo("testProvider", "testProviderId")); + + // when + member.register("testNickname"); + + // then + assertEquals(MemberRole.USER, member.getRole()); + } + + @Test + void 회원가입시_일반멤버이면_예외가_발생한다() { + // given + Member member = Member.createNormalMember(profile); + + // when & then + assertThatThrownBy(() -> member.register("testNickname")) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.MEMBER_ALREADY_REGISTERED.getMessage()); + } +} diff --git a/src/test/java/com/depromeet/domain/mission/controller/MissionControllerTest.java b/src/test/java/com/depromeet/domain/mission/controller/MissionControllerTest.java new file mode 100644 index 000000000..230bf4309 --- /dev/null +++ b/src/test/java/com/depromeet/domain/mission/controller/MissionControllerTest.java @@ -0,0 +1,266 @@ +package com.depromeet.domain.mission.controller; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import com.depromeet.domain.mission.api.MissionController; +import com.depromeet.domain.mission.application.MissionService; +import com.depromeet.domain.mission.domain.ArchiveStatus; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.domain.mission.dto.response.*; +import com.depromeet.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.domain.mission.dto.response.MissionFindResponse; +import com.depromeet.domain.mission.dto.response.MissionUpdateResponse; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.security.JwtAuthenticationFilter; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(MissionController.class) +@AutoConfigureMockMvc(addFilters = false) +@MockBean({JpaMetamodelMappingContext.class, JwtAuthenticationFilter.class}) +class MissionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private MissionService missionService; + + @Test + void 공부미션을_생성한다() throws Exception { + // given + MissionCreateRequest createRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + + given(missionService.createMission(any())) + .willReturn( + new MissionCreateResponse( + 1L, + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL)); + // when, then + ResultActions perform = + mockMvc.perform( + post("/missions") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf()) + .content(objectMapper.writeValueAsString(createRequest))); + + perform.andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.missionId").exists()) + .andExpect(jsonPath("$.data.name").exists()) + .andExpect(jsonPath("$.data.name").value("testMissionName")) + .andExpect(jsonPath("$.data.content").value("testMissionContent")) + .andExpect(jsonPath("$.data.category").value("STUDY")) + .andExpect(jsonPath("$.data.visibility").value("ALL")) + .andDo(print()); + } + + @Test + void 미션_생성하는데_이름은_null일_수_없다() throws Exception { + // given + MissionCreateRequest createRequest = + new MissionCreateRequest( + null, "testMissionContent", MissionCategory.STUDY, MissionVisibility.ALL); + + // when, then + ResultActions perform = + mockMvc.perform( + post("/missions") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf()) + .content(objectMapper.writeValueAsString(createRequest))); + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect( + jsonPath("$.data.errorClassName").value("MethodArgumentNotValidException")) + .andExpect(jsonPath("$.data.message").value("이름은 비워둘 수 없습니다.")) + .andDo(print()); + } + + @Test + void 미션_단건_조회한다() throws Exception { + // given + given(missionService.findOneMission(any())) + .willReturn( + new MissionFindResponse( + 1L, + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL, + ArchiveStatus.NONE, + 1)); + + // when, then + ResultActions perform = + mockMvc.perform( + get("/missions/{missionId}", 1L) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf())); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.missionId").exists()) + .andExpect(jsonPath("$.data.name").exists()) + .andExpect(jsonPath("$.data.name").value("testMissionName")); + } + + @Test + void 미션_리스트를_조회한다() throws Exception { + // given + int size = 3; + long lastId = 4; + LocalDateTime ttlFinishedAt = LocalDateTime.now().plusMinutes(10); + Member member = + Member.createNormalMember(Profile.createProfile("testNickname", "testImageUrl")); + LocalDateTime missionStartedAt = LocalDateTime.of(2023, 12, 1, 1, 5, 0); + LocalDateTime missionFinishedAt = missionStartedAt.plusWeeks(2); + Mission mission = + Mission.createMission( + "testMissionName_1", + "testMissionContent_1", + 1, + MissionCategory.STUDY, + MissionVisibility.ALL, + missionStartedAt, + missionFinishedAt, + member); + + List missionList = + Arrays.asList( + MissionFindAllResponse.of(mission, MissionStatus.NONE, null, null), + MissionFindAllResponse.of(mission, MissionStatus.COMPLETED, null, null), + MissionFindAllResponse.of( + mission, MissionStatus.REQUIRED, ttlFinishedAt, null)); + given(missionService.findAllMission()).willReturn(missionList); + + // when, then + ResultActions perform = + mockMvc.perform( + get("/missions") + .param("size", String.valueOf(size)) + .param("lastId", String.valueOf(lastId)) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf())); + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()", is(size))) + .andDo(print()); + } + + @Test + void 미션_공개여부를_팔로워로_수정한다() throws Exception { + // given + MissionUpdateRequest updateRequest = + new MissionUpdateRequest( + "testMissionName", "testMissionContent", MissionVisibility.NONE); + given(missionService.updateMission(any(), any())).willReturn(new MissionUpdateResponse(1L)); + + // when, then + ResultActions perform = + mockMvc.perform( + put("/missions/{missionId}", 1L) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf()) + .content(objectMapper.writeValueAsString(updateRequest))); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.missionId").exists()) + .andExpect(jsonPath("$.data.missionId").value(1L)) + .andDo(print()); + } + + @Test + void 미션이름을_null로_수정할_수_없다() throws Exception { + // given + MissionUpdateRequest updateRequest = + new MissionUpdateRequest(null, "testMissionContent", MissionVisibility.NONE); + given(missionService.updateMission(any(), any())).willReturn(new MissionUpdateResponse(1L)); + + // when, then + ResultActions perform = + mockMvc.perform( + put("/missions/{missionId}", 1L) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf()) + .content(objectMapper.writeValueAsString(updateRequest))); + + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect( + jsonPath("$.data.errorClassName").value("MethodArgumentNotValidException")) + .andExpect(jsonPath("$.data.message").value("이름은 비워둘 수 없습니다.")) + .andDo(print()); + } + + @Test + void 미션_단건_삭제한다() throws Exception { + // given + Long missionId = 1L; + doNothing().when(missionService).deleteMission(missionId); + + // when, then + mockMvc.perform( + delete("/missions/{missionId}", missionId) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void 존재하지_않는_미션을_삭제한다() throws Exception { + // given + Long nonExistingMissionId = 999L; + + // when + doThrow(new CustomException(ErrorCode.MISSION_NOT_FOUND)) + .when(missionService) + .deleteMission(nonExistingMissionId); + + // then + mockMvc.perform( + delete("/missions/{missionId}", nonExistingMissionId) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNotFound()) + .andDo(print()); + } +} diff --git a/src/test/java/com/depromeet/domain/mission/domain/MissionTest.java b/src/test/java/com/depromeet/domain/mission/domain/MissionTest.java new file mode 100644 index 000000000..9e1defadb --- /dev/null +++ b/src/test/java/com/depromeet/domain/mission/domain/MissionTest.java @@ -0,0 +1,89 @@ +package com.depromeet.domain.mission.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MissionTest { + + Member member; + + @BeforeEach + void setUp() { + Profile profile = Profile.createProfile("testNickname", "testProfileImageUrl"); + member = Member.createNormalMember(profile); + } + + @Test + void 미션_카테고리_기타값_테스트() { + // given + LocalDateTime startedAt = LocalDateTime.of(2023, 12, 1, 1, 5, 0); + LocalDateTime finishedAt = LocalDateTime.of(2023, 12, 15, 1, 37, 0); + Mission mission = + Mission.createMission( + "testMissionName", + "testMissionContent", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + startedAt, + finishedAt, + member); + + // when + MissionCategory category = mission.getCategory(); + + // then + assertEquals(MissionCategory.ETC, category); + } + + @Test + void 미션_공개여부_공개_테스트() { + // given + LocalDateTime startedAt = LocalDateTime.of(2023, 12, 1, 1, 5, 0); + LocalDateTime finishedAt = LocalDateTime.of(2023, 12, 15, 1, 37, 0); + Mission mission = + Mission.createMission( + "testMissionName", + "testMissionContent", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + startedAt, + finishedAt, + member); + + // when + MissionVisibility visibility = mission.getVisibility(); + + // then + assertEquals(MissionVisibility.ALL, visibility); + } + + @Test + void 미션_아카이빙_기본값_NONE_테스트() { + // given + LocalDateTime startedAt = LocalDateTime.of(2023, 12, 1, 1, 5, 0); + LocalDateTime finishedAt = LocalDateTime.of(2023, 12, 15, 1, 37, 0); + Mission mission = + Mission.createMission( + "testMissionName", + "testMissionContent", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + startedAt, + finishedAt, + member); + + // when + ArchiveStatus archiveStatus = mission.getArchiveStatus(); + + // then + assertEquals(ArchiveStatus.NONE, archiveStatus); + } +} diff --git a/src/test/java/com/depromeet/domain/mission/repository/MissionRepositoryTest.java b/src/test/java/com/depromeet/domain/mission/repository/MissionRepositoryTest.java new file mode 100644 index 000000000..bb77a8db4 --- /dev/null +++ b/src/test/java/com/depromeet/domain/mission/repository/MissionRepositoryTest.java @@ -0,0 +1,183 @@ +package com.depromeet.domain.mission.repository; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import com.depromeet.TestQuerydslConfig; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.mission.dto.request.MissionCreateRequest; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@Import(TestQuerydslConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles(value = "test") +class MissionRepositoryTest { + + @Autowired private MissionRepository missionRepository; + @Autowired private MemberRepository memberRepository; + private Member saveMember; + + @BeforeEach + void setUp() { + missionRepository.deleteAll(); + Member member = + Member.createNormalMember(Profile.createProfile("testNickname", "testImageUrl")); + saveMember = memberRepository.save(member); + } + + @Test + void 미션을_생성한다() { + // given + LocalDateTime startedAt = LocalDateTime.now(); + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + + // when + Mission saveMission = + missionRepository.save( + Mission.createMission( + missionCreateRequest.name(), + missionCreateRequest.content(), + 1, + missionCreateRequest.category(), + missionCreateRequest.visibility(), + startedAt, + startedAt.plusWeeks(2), + saveMember)); + + // then + assertThat(saveMission.getId()).isNotNull(); + assertThat(saveMission.getName()).isEqualTo(missionCreateRequest.name()); + assertThat(saveMission.getContent()).isEqualTo(missionCreateRequest.content()); + assertThat(saveMission.getCategory()).isEqualTo(missionCreateRequest.category()); + assertThat(saveMission.getVisibility()).isEqualTo(missionCreateRequest.visibility()); + assertThat(saveMission.getStartedAt()).isEqualTo(startedAt); + } + + @Test + void 미션이름_20자_초과하면_미션생셩_실패한다() { + // given + LocalDateTime startedAt = LocalDateTime.now(); + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionNameMoreThan", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + + // when + Mission mission = + Mission.createMission( + missionCreateRequest.name(), + missionCreateRequest.content(), + 1, + missionCreateRequest.category(), + missionCreateRequest.visibility(), + startedAt, + startedAt.plusWeeks(2), + saveMember); + + // then + assertThatThrownBy(() -> missionRepository.save(mission)) + // instance 검증 + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 단건_미션을_조회한다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + LocalDateTime startedAt = LocalDateTime.now(); + Mission saveMission = + missionRepository.save( + Mission.createMission( + missionCreateRequest.name(), + missionCreateRequest.content(), + 1, + missionCreateRequest.category(), + missionCreateRequest.visibility(), + startedAt, + startedAt.plusWeeks(2), + saveMember)); + // when + Optional findMission = missionRepository.findById(saveMission.getId()); + + // then + assertThat(findMission).isPresent(); + assertThat(findMission.get().getId()).isEqualTo(saveMission.getId()); + assertThat(findMission.get().getName()).isEqualTo(saveMission.getName()); + assertThat(findMission.get().getContent()).isEqualTo(saveMission.getContent()); + assertThat(findMission.get().getCategory()).isEqualTo(saveMission.getCategory()); + assertThat(findMission.get().getVisibility()).isEqualTo(saveMission.getVisibility()); + assertThat(findMission.get().getStartedAt()).isEqualTo(saveMission.getStartedAt()); + assertThat(findMission.get().getFinishedAt()).isEqualTo(saveMission.getFinishedAt()); + } + + @Test + void 미션_리스트_조회한다() { + // given + LocalDateTime startedAt = LocalDateTime.now(); + + IntStream.range(1, 5) + .mapToObj( + i -> + new MissionCreateRequest( + "testMissionName_" + i, + "testMissionContent_" + i, + MissionCategory.STUDY, + MissionVisibility.ALL)) + .forEach( + request -> + missionRepository.save( + Mission.createMission( + request.name(), + request.content(), + 1, + request.category(), + request.visibility(), + startedAt, + startedAt.plusWeeks(2), + saveMember))); + + // when + List missionList = missionRepository.findMissionsWithRecords(saveMember.getId()); + + // then + assertThat(missionList.size()).isEqualTo(4); + assertThat(missionList) + .hasSize(4) + .extracting("id", "name", "content") + .containsExactlyInAnyOrder( + tuple(4L, "testMissionName_4", "testMissionContent_4"), + tuple(3L, "testMissionName_3", "testMissionContent_3"), + tuple(2L, "testMissionName_2", "testMissionContent_2"), + tuple(1L, "testMissionName_1", "testMissionContent_1")); + } +} diff --git a/src/test/java/com/depromeet/domain/mission/service/MissionServiceTest.java b/src/test/java/com/depromeet/domain/mission/service/MissionServiceTest.java new file mode 100644 index 000000000..3a18d7e9a --- /dev/null +++ b/src/test/java/com/depromeet/domain/mission/service/MissionServiceTest.java @@ -0,0 +1,245 @@ +package com.depromeet.domain.mission.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import com.depromeet.DatabaseCleaner; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.application.MissionService; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.domain.mission.dto.response.MissionFindAllResponse; +import com.depromeet.domain.mission.dto.response.MissionFindResponse; +import com.depromeet.domain.mission.dto.response.MissionUpdateResponse; +import com.depromeet.global.security.PrincipalDetails; +import com.depromeet.global.util.MemberUtil; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +class MissionServiceTest { + + @Autowired private MissionService missionService; + @Autowired private MissionRepository missionRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private DatabaseCleaner databaseCleaner; + @Autowired private EntityManager entityManager; + @Autowired private MemberUtil memberUtil; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + PrincipalDetails principal = new PrincipalDetails(1L, "USER"); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, "password", principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + Member guestMember = Member.createGuestMember("username", "password"); + memberRepository.save(guestMember); + } + + @Test + void 공부미션을_생성한다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + + // when + MissionCreateResponse mission = missionService.createMission(missionCreateRequest); + + // then + assertNotNull(mission); + assertEquals("testMissionName", mission.name()); + assertEquals("testMissionContent", mission.content()); + assertEquals(MissionCategory.STUDY, mission.category()); + assertEquals(MissionVisibility.ALL, mission.visibility()); + } + + @Test + void 미션_단건_조회한다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse saveMission = missionService.createMission(missionCreateRequest); + + // when + MissionFindResponse findMission = missionService.findOneMission(saveMission.missionId()); + + // then + assertEquals(findMission.name(), "testMissionName"); + assertEquals(findMission.content(), "testMissionContent"); + assertEquals(findMission.category(), MissionCategory.STUDY); + assertEquals(findMission.visibility(), MissionVisibility.ALL); + } + + @Test + @Transactional + void 미션_리스트를_조회한다() { + // given + LocalDateTime startedAt = LocalDateTime.now(); + + IntStream.range(1, 5) + .mapToObj( + i -> + new MissionCreateRequest( + "testMissionName_" + i, + "testMissionContent_" + i, + MissionCategory.STUDY, + MissionVisibility.ALL)) + .forEach( + request -> + entityManager.persist( + Mission.createMission( + request.name(), + request.content(), + 1, + request.category(), + request.visibility(), + startedAt, + startedAt.plusWeeks(2), + memberUtil.getCurrentMember()))); + + // when + List missionList = missionService.findAllMission(); + + // then + assertThat(missionList.size()).isEqualTo(4); + assertThat(missionList) + .extracting("missionId", "name", "content") + .containsExactlyInAnyOrder( + tuple(1L, "testMissionName_1", "testMissionContent_1"), + tuple(2L, "testMissionName_2", "testMissionContent_2"), + tuple(3L, "testMissionName_3", "testMissionContent_3"), + tuple(4L, "testMissionName_4", "testMissionContent_4")); + } + + @Test + void 미션_단건_수정한다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse saveMission = missionService.createMission(missionCreateRequest); + MissionUpdateRequest missionUpdateRequest = + new MissionUpdateRequest("modifyName", "modifyContent", MissionVisibility.FOLLOWER); + + // when + MissionUpdateResponse modifyMission = + missionService.updateMission(missionUpdateRequest, saveMission.missionId()); + + // expected + assertEquals(modifyMission.missionId(), 1L); + } + + @Test + void 미션이름에_null값은_미션수정_실패한다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse saveMission = missionService.createMission(missionCreateRequest); + MissionUpdateRequest missionUpdateRequest = + new MissionUpdateRequest(null, "modifyContent", MissionVisibility.FOLLOWER); + + // when, then + assertThatThrownBy( + () -> + missionService.updateMission( + missionUpdateRequest, saveMission.missionId())) + // instance 검증 + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 미션이름_20자_초과하면_미션수정_실패한다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse saveMission = missionService.createMission(missionCreateRequest); + MissionUpdateRequest missionUpdateRequest = + new MissionUpdateRequest( + "modifyMissionName_test", "modifyContent", MissionVisibility.FOLLOWER); + + // when, then + assertThatThrownBy( + () -> + missionService.updateMission( + missionUpdateRequest, saveMission.missionId())) + // instance 검증 + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 미션_단건_삭제한다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + MissionCreateResponse saveMission = missionService.createMission(missionCreateRequest); + + // when + missionService.deleteMission(saveMission.missionId()); + + // then + assertThat(missionRepository.findAll()).isEmpty(); + assertThat(missionRepository.count()).isEqualTo(0); + } + + @Test + void 미션_삭제_시_존재하지_않는_아이디라면_삭제되지_않는다() { + // given + MissionCreateRequest missionCreateRequest = + new MissionCreateRequest( + "testMissionName", + "testMissionContent", + MissionCategory.STUDY, + MissionVisibility.ALL); + missionService.createMission(missionCreateRequest); + + // when + missionService.deleteMission(200L); + + // then + assertThat(missionRepository.findAll()).isNotEmpty(); + } +} diff --git a/src/test/java/com/depromeet/domain/missionRecord/application/MissionRecordServiceTest.java b/src/test/java/com/depromeet/domain/missionRecord/application/MissionRecordServiceTest.java new file mode 100644 index 000000000..571ddd167 --- /dev/null +++ b/src/test/java/com/depromeet/domain/missionRecord/application/MissionRecordServiceTest.java @@ -0,0 +1,77 @@ +package com.depromeet.domain.missionRecord.application; + +import static org.mockito.Mockito.when; + +import com.depromeet.DatabaseCleaner; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.OauthInfo; +import com.depromeet.domain.mission.application.MissionService; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.util.SecurityUtil; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class MissionRecordServiceTest { + + @Autowired MissionRecordService missionRecordService; + @Autowired MissionService missionService; + @Autowired MemberRepository memberRepository; + @Autowired MissionRepository missionRepository; + @Autowired DatabaseCleaner databaseCleaner; + @MockBean SecurityUtil securityUtil; + + private Member member; + private Mission mission; + private LocalDateTime now = LocalDateTime.now(); + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + when(securityUtil.getCurrentMemberId()).thenReturn(1L); + + member = Member.createGuestMember(OauthInfo.createOauthInfo("test", "test")); + memberRepository.save(member); + + mission = + Mission.createMission( + "test", + "test", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + now, + now.plusWeeks(2), + member); + missionRepository.save(mission); + } + + @Test + void 진행중인_미션기록을_삭제한다() { + // given + missionRecordService.createMissionRecord( + new MissionRecordCreateRequest(mission.getId(), now, now.plusMinutes(10), 10, 0)); + + // when + missionRecordService.deleteInProgressMissionRecord(); + + // then + Long missionId = mission.getId(); + + Assertions.assertThrows( + CustomException.class, () -> missionRecordService.findOneMissionRecord(missionId)); + } +} diff --git a/src/test/java/com/depromeet/domain/missionRecord/domain/MissionRecordTest.java b/src/test/java/com/depromeet/domain/missionRecord/domain/MissionRecordTest.java new file mode 100644 index 000000000..2f98ad4e5 --- /dev/null +++ b/src/test/java/com/depromeet/domain/missionRecord/domain/MissionRecordTest.java @@ -0,0 +1,108 @@ +package com.depromeet.domain.missionRecord.domain; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import java.lang.reflect.Field; +import java.time.Duration; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class MissionRecordTest { + + @Nested + class 미션기록_생성_시 { + Profile profile = Profile.createProfile("testNickname", "testProfileImageUrl"); + Member member = Member.createNormalMember(profile); + LocalDateTime missionStartedAt = LocalDateTime.of(2023, 12, 1, 1, 5, 0); + LocalDateTime missionFinishedAt = missionStartedAt.plusWeeks(2); + Mission mission = + Mission.createMission( + "testMissionName", + "testMissionContent", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + missionStartedAt, + missionFinishedAt, + member); + + @Test + void 업로드_상태_DEFAULT값은_NONE이다() { + // given + LocalDateTime missionRecordStartedAt = LocalDateTime.of(2023, 12, 15, 1, 5, 0); + LocalDateTime missionRecordFinishedAt = + missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + Duration duration = Duration.ofMinutes(32).plusSeconds(14); + MissionRecord missionRecord = + MissionRecord.createMissionRecord( + duration, missionRecordStartedAt, missionRecordFinishedAt, mission); + + // when + ImageUploadStatus uploadStatus = missionRecord.getUploadStatus(); + + // then + assertEquals(ImageUploadStatus.NONE, uploadStatus); + } + + @Nested + class 미션기록의_이미지_업로드_상태를 { + @Test + void PENDING으로_변경할때_업로드_상태가_NONE이_아니라면_예외가_발생한다() + throws NoSuchFieldException, IllegalAccessException { + // given + LocalDateTime missionRecordStartedAt = LocalDateTime.of(2023, 12, 15, 1, 5, 0); + LocalDateTime missionRecordFinishedAt = + missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + Duration duration = Duration.ofMinutes(32).plusSeconds(14); + MissionRecord missionRecord = + MissionRecord.createMissionRecord( + duration, missionRecordStartedAt, missionRecordFinishedAt, mission); + + Field uploadStatusField = MissionRecord.class.getDeclaredField("uploadStatus"); + uploadStatusField.setAccessible(true); + uploadStatusField.set(missionRecord, ImageUploadStatus.PENDING); + + // when, then + assertThatThrownBy(() -> missionRecord.updateUploadStatusPending()) + .isInstanceOf(CustomException.class) + .hasMessage( + ErrorCode.MISSION_RECORD_UPLOAD_STATUS_IS_NOT_NONE.getMessage()); + } + + @Test + void COMPLETE로_변경할때_업로드_상태가_PENDING상태가_아니라면_예외가_발생한다() + throws NoSuchFieldException, IllegalAccessException { + // given + LocalDateTime missionRecordStartedAt = LocalDateTime.of(2023, 12, 15, 1, 5, 0); + LocalDateTime missionRecordFinishedAt = + missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + Duration duration = Duration.ofMinutes(32).plusSeconds(14); + MissionRecord missionRecord = + MissionRecord.createMissionRecord( + duration, missionRecordStartedAt, missionRecordFinishedAt, mission); + + Field uploadStatusField = MissionRecord.class.getDeclaredField("uploadStatus"); + uploadStatusField.setAccessible(true); + uploadStatusField.set(missionRecord, ImageUploadStatus.COMPLETE); + + // when, then + assertThatThrownBy( + () -> + missionRecord.updateUploadStatusComplete( + "testRemark", "testImageUrl")) + .isInstanceOf(CustomException.class) + .hasMessage( + ErrorCode.MISSION_RECORD_UPLOAD_STATUS_IS_NOT_PENDING.getMessage()); + } + } + } +} diff --git a/src/test/java/com/depromeet/global/util/MemberUtilTest.java b/src/test/java/com/depromeet/global/util/MemberUtilTest.java new file mode 100644 index 000000000..e50bf3d5c --- /dev/null +++ b/src/test/java/com/depromeet/global/util/MemberUtilTest.java @@ -0,0 +1,46 @@ +package com.depromeet.global.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.depromeet.DatabaseCleaner; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.global.security.PrincipalDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class MemberUtilTest { + + @Autowired private MemberUtil memberUtil; + @Autowired private MemberRepository memberRepository; + @Autowired private DatabaseCleaner databaseCleaner; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } + + @Test + void 현재_로그인한_회원의_정보를_정상적으로_반환한다() { + // given + PrincipalDetails principal = new PrincipalDetails(1L, "USER"); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, "password", principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + Member guestMember = Member.createGuestMember("username", "password"); + Member savedMember = memberRepository.save(guestMember); + // when + Member currentMember = memberUtil.getCurrentMember(); + // then + assertEquals(savedMember.getId(), currentMember.getId()); + } +} diff --git a/src/test/java/com/depromeet/global/util/SpringEnvironmentUtilTest.java b/src/test/java/com/depromeet/global/util/SpringEnvironmentUtilTest.java new file mode 100644 index 000000000..fa7ed177a --- /dev/null +++ b/src/test/java/com/depromeet/global/util/SpringEnvironmentUtilTest.java @@ -0,0 +1,116 @@ +package com.depromeet.global.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import com.depromeet.global.common.constants.EnvironmentConstants; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; + +@ExtendWith(MockitoExtension.class) +class SpringEnvironmentUtilTest { + @Mock private Environment environment; + + @InjectMocks private SpringEnvironmentUtil springEnvironmentUtil; + + private final String[] PROD_ARRAY = new String[] {EnvironmentConstants.PROD.getValue()}; + private final String[] DEV_ARRAY = new String[] {EnvironmentConstants.DEV.getValue()}; + private final String[] LOCAL_ARRAY = new String[] {EnvironmentConstants.LOCAL.getValue()}; + + @Test + void 상용_환경이라면_isProdProfile은_true를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(PROD_ARRAY); + + // when + // then + assertTrue(springEnvironmentUtil.isProdProfile()); + } + + @Test + void 상용_환경이_아니라면_isProdProfile은_false를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertFalse(springEnvironmentUtil.isProdProfile()); + } + + @Test + void 테스트_환경이라면_isDevProfile은_true를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertTrue(springEnvironmentUtil.isDevProfile()); + } + + @Test + void 테스트_환경이_아니라면_isDevProfile은_false를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(LOCAL_ARRAY); + + // when + // then + assertFalse(springEnvironmentUtil.isDevProfile()); + } + + @Test + void 로컬_환경이라면_isProdAndDevProfile은_false를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(LOCAL_ARRAY); + + // when + // then + assertFalse(springEnvironmentUtil.isProdAndDevProfile()); + } + + @Test + void 로컬_환경이_아니라면_isProdAndDevProfile은_true를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertTrue(springEnvironmentUtil.isProdAndDevProfile()); + } + + @Test + void 상용_환경이라면_getCurrentProfile는은_prod를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(PROD_ARRAY); + + // when + // then + assertEquals( + springEnvironmentUtil.getCurrentProfile(), EnvironmentConstants.PROD.getValue()); + } + + @Test + void 테스트_환경이라면_getCurrentProfile는은_dev를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertEquals( + springEnvironmentUtil.getCurrentProfile(), EnvironmentConstants.DEV.getValue()); + } + + @Test + void 로컬_환경이라면_getCurrentProfile는은_local을_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(LOCAL_ARRAY); + + // when + // then + assertEquals( + springEnvironmentUtil.getCurrentProfile(), EnvironmentConstants.LOCAL.getValue()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 000000000..915f6cf78 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,7 @@ +spring: + config: + activate: + on-profile: "test" + + datasource: + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL