diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..b99a43bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,8 @@ +## ✨ Description + +> 기능에 대한 설명을 적습니다. + +## 📌 구현 내용 + +- [ ] 구현한 기능을 적습니다 (1) +- [ ] 구현한 기능을 적습니다 (2) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..35752da7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## 📄 Description + +- close : # + +> 기능에 대한 설명 + +## 📌 구현 내용 + +- [ ] 구현한 기능을 적습니다 (1) +- [ ] 구현한 기능을 적습니다 (2) + +## ✅ PR 포인트 + +없으면 생략 가능 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..ebc7e925 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Ahpuh CI Deploy + +on: + workflow_dispatch: + +env: + S3_BUCKET_NAME: team8-ahpuh + PROJECT_NAME: surf + DB_URL: ${{ secrets.DB_URL }} + USERNAME: ${{ secrets.DB_USERNAME }} + DB_PWD: ${{ secrets.DB_PW }} + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v1 + with: + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash + + - name: Build with Gradle + run: ./gradlew clean build + shell: bash + + - name: Make zip file + run: zip -r ./$GITHUB_SHA.zip . + shell: bash + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Upload to S3 + run: aws s3 cp --region ap-northeast-2 ./$GITHUB_SHA.zip s3://$S3_BUCKET_NAME/$PROJECT_NAME/$GITHUB_SHA.zip + + - name: Code Deploy + run: aws deploy create-deployment --application-name surf-app --deployment-config-name CodeDeployDefault.AllAtOnce --deployment-group-name develop --s3-location bucket=$S3_BUCKET_NAME,bundleType=zip,key=$PROJECT_NAME/$GITHUB_SHA.zip diff --git a/.github/workflows/gradle-build-and-test.yml b/.github/workflows/gradle-build-and-test.yml new file mode 100644 index 00000000..f8a13880 --- /dev/null +++ b/.github/workflows/gradle-build-and-test.yml @@ -0,0 +1,58 @@ +name: Ahpuh CI Build & Test + +on: + push: + branches: + - master + - develop + pull_request: + branches: + - master + - develop + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v1 + with: + java-version: '17' + + - name: Cache Gradle packages + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + shell: bash + + - name: Build with Gradle + run: ./gradlew clean build + shell: bash + + - name: Publish Unit Test Results + uses: EnricoMi/publish-unit-test-result-action@v1 + if: ${{ always() }} # 테스트가 실패해도 Report를 보기 위해 always로 설정 + with: + files: build/test-results/**/*.xml # Report 저장 경로 + + - name: Cleanup Gradle Cache + if: ${{ always() }} + run: | + rm -f ~/.gradle/caches/modules-2/modules-2.lock + rm -f ~/.gradle/caches/modules-2/gc.properties + +### Test를 통과해야만 merge 가능하게 설정 방법 ### +# Settings -> Branches -> Add rule +# Branch name pattern (Branch name) 설정 +# Require status checks to pass before merging 설정 diff --git a/.gitignore b/.gitignore index ccbd085e..c6ca1b19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,56 +1,11 @@ - -# Created by https://www.toptal.com/developers/gitignore/api/gradle,intellij,java -# Edit at https://www.toptal.com/developers/gitignore?templates=gradle,intellij,java +/logs ### Intellij ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr +/.idea/ # CMake cmake-build-*/ -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - # File-based project format *.iws @@ -63,51 +18,12 @@ out/ # JIRA plugin atlassian-ide-plugin.xml -# Cursive Clojure plugin -.idea/replstate.xml - # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### Intellij Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -# https://plugins.jetbrains.com/plugin/7973-sonarlint -.idea/**/sonarlint/ - -# SonarQube Plugin -# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin -.idea/**/sonarIssues.xml - -# Markdown Navigator plugin -# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced -.idea/**/markdown-navigator.xml -.idea/**/markdown-navigator-enh.xml -.idea/**/markdown-navigator/ - -# Cache file creation bug -# See https://youtrack.jetbrains.com/issue/JBR-2257 -.idea/$CACHE_FILE$ - -# CodeStream plugin -# https://plugins.jetbrains.com/plugin/12206-codestream -.idea/codestream.xml - ### Java ### # Compiled class file *.class @@ -157,5 +73,3 @@ gradle-app.setting .project # JDT-specific (Eclipse Java Development Tools) .classpath - -# End of https://www.toptal.com/developers/gitignore/api/gradle,intellij,java \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 73f69e09..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 98cd9e71..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -backend \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b27dd6e1..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index b3e9cbd3..00000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index fe0b0dab..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 797acea5..00000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/saveactions_settings.xml b/.idea/saveactions_settings.xml deleted file mode 100644 index db1ef954..00000000 --- a/.idea/saveactions_settings.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..f686d0f7 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ + + + + +# **내 인생 성장곡선 사이트 - _**Surf.**_ 🏄🏻‍♂️** + +인생은 surfing 을 타는 것처럼 즐겁지만, suffering 또한 피할 수 없다. + +피할 수 없다면 기록하고 공유하자! Surf 를 통해 🌊🏄‍♀️🏄🏄🏻‍♂️ + +**Surf의 백엔드 레포입니다 😊** + +--- + +## 👨‍💻팀원 소개 + +| [최승은](https://github.com/cse0518) | [박수빈](https://github.com/suebeen) | [박정미](https://github.com/Jummi10) | [전효희](https://github.com/kwhyo) | +| :---: | :---: | :---: | :---: | +| | | | | +| 팀장, 개발자 | 스크럼 마스터, 개발자 | 개발자 | 개발자 | + +
+ +## 📍프로젝트 목표 및 상세 설명 + +열심히 달려온 나 자신! 열심히는 하고 있는데 **내가 얼마나 발전했는지** 기록하는 공간은 없을까? 그냥 일기는 메모장에라도 적을 수 있고, 블로그는 이미 무수히 존재하고, **색다른 방법**으로 동기부여 받고 기록하고 공유하는 +그런 공간이 필요해! 🙆‍♀️ + +- **성장곡선**으로 한눈에 내 인생을 돌아보기 +- 남들의 성장곡선을 보며 **동기부여**도 받기 +- 곡선의 특정 구간마다 기록도 남기기 +- 곡선이 아닌 기록들만 모아서 보기 +- 필요하다면 **포트폴리오**로도 사용 가능하기 + +
+ +## 🛠️개발 언어 및 활용 기술 + +**개발 환경** + +- **Springboot** 로 웹 어플리케이션 서버를 구축했어요. +- 빌드도구는 **Gradle**을 사용했어요. +- 다양한 기능과 안정성을 위해 LTS 버전인 **Java 17** 버전을 사용했어요. +- **Spring Data JPA(Hibernate)** 로 객체 지향 데이터 로직을 작성했어요. +- **QueryDSL** 로 컴파일 시점에 SQL 오류를 감지해요. JPA 인터페이스로 해결하기 힘든 동적이고 복잡한 query를 보완하고 더 가독성 높은 코드를 작성할 수 있어요. +- 데이터베이스는 **MySQL**을 사용했어요. + +**Infrastructure** + +- **AWS EC2**를 사용해 서버를 구축했어요. +- **S3** 로 파일을 업로드하고 보관해요. + +**협업 관리** + +- **Github Issue** 으로 이슈를 관리해요. +- **Git-flow 전략**을 사용하여 브랜치를 관리해요. +- **Slack / Gather / Notion** 으로 소통해요. +- **Postman** 으로 작성한 API 문서를 통해 클라이언트와 소통해요. + +**CI/CD** + +- **Github Actions** 로 코드 퀄리티와 테스트를 검사해요. +- **Jenkins** 로 백엔드 코드의 지속적인 배포를 진행해요. +- **Codacy** 로 지속적인 코드 퀄리티 개선을 진행해요. +- **JACOCO** 로 테스트 커버리지를 검사해요. + +**Security** + +- **Spring Security** 를 사용했어요. +- 로그인 시에는 **JWT** 토큰을 발행하여 서버의 별도 저장소 없이 로그인을 유지할 수 있어요. +- CertBot 으로 Let’s Encrypt **SSL** 인증서를 발급받았어요. +- **Nginx** 가 프록시로 8080 포트를 바라보게 설정했어요. + +
+ +## ⚙시스템 아키텍처 +![최종](https://user-images.githubusercontent.com/55528172/147193318-77fd4086-33a1-4e71-aa46-2f36a474eff1.png) + +
+ +## 🏗️설계 +### ERD 설계 +![Untitled](https://user-images.githubusercontent.com/55528172/147193431-1410ff56-67b9-4eee-ba16-1b0a3a60c447.png) + + +### 설계 문서 +[🐄MoSCoW 구경가기](https://www.notion.so/MoSCoW-4f7d9e241bc24e84ac7c8213ef1d2c85)
+[🔍SURF API 설계 구경가기](https://www.notion.so/6785f7446eba4a0b82d384d025cb28a6)
+[📑Postman API 명세서](https://documenter.getpostman.com/view/15409285/UVRAJnUD#50ff4a3f-1d02-4f50-9870-9c0b22fa2a6f)
+ +
+ +## 🤳데모 화면 +| **로그인** | **메인 화면** - Surf 첫 페이지 | **메인 화면** - 특정 category 선택 | +| :---: | :---: | :---: | +| ![로그인](https://user-images.githubusercontent.com/55528172/147193938-07d0547f-740b-428c-8ea6-25c8a6e85f3f.gif) | ![메인 페이지 - 첫 화면](https://user-images.githubusercontent.com/55528172/147193958-a062bdb3-a82a-41a2-8d2c-dd4ecd9882ba.gif) | ![메인 페이지 - 카테고리 선택](https://user-images.githubusercontent.com/55528172/147193999-6313d4d4-fe2b-4842-9b07-f3fa86835d56.gif) | + +| **게시글 작성** | **무한 스크롤** | **마이 페이지** - 내 정보 수정 | +| :---: | :---: | :---: | +| ![포스트 생성](https://user-images.githubusercontent.com/55528172/147194169-b8d17790-bb44-4275-87d1-77156fa48667.gif) | ![무한 스크롤](https://user-images.githubusercontent.com/55528172/147194204-14e4475b-dc85-41b4-8995-8d91b7fe286a.gif) | ![마이 페이지 - 정보 수정](https://user-images.githubusercontent.com/55528172/147194226-f3ae8cf6-1894-4420-88a1-e340d426fd25.gif) | + +| **대시보드** | **카드 페이지** | **카드 페이지** - 해당 월별 기록 리스트 | +| :---: | :---: | :---: | +| ![대시보드](https://user-images.githubusercontent.com/55528172/147194386-80912927-d4a4-4901-aea2-e241f62c775f.gif) | ![카드 페이지](https://user-images.githubusercontent.com/55528172/147194395-060842b6-9ad4-4ef5-a5ba-7d3904906833.gif) | ![카드 페이지 - 월별 리스트](https://user-images.githubusercontent.com/55528172/147194403-0f9236bb-3ce1-445d-aca1-775cb26d8737.gif) | +| 마이 페이지에서 이동 | 연도별 필터링, 해당 달의 작성 일수 확인 가능 | 카드 선택시 | + +___ + +## 🌻프론트 깃 레포 + +[👨‍💻**SURF** Front Git Repository](https://github.com/prgrms-web-devcourse/Team_Ahpuh_Surf_FE) + +## 🍁팀 노션 + +[🔍**SURF** 팀 노션 구경가기](https://www.notion.so/8-Ah-puh-Surf-ccc0a5922b8e4f638d6e897b4eb575a6) diff --git a/build.gradle b/build.gradle index c3a37de3..9e4b30ce 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,9 @@ plugins { id 'org.springframework.boot' version '2.6.1' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'org.asciidoctor.convert' version '1.5.8' + id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" id 'java' + id 'jacoco' } group = 'org.ahpuh' @@ -27,7 +29,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'com.google.guava:guava:23.0' + implementation 'com.auth0:java-jwt:3.18.2' + implementation 'org.apache.commons:commons-lang3:3.12.0' + implementation 'com.querydsl:querydsl-jpa:5.0.0' + implementation 'com.querydsl:querydsl-apt:5.0.0' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.122' + implementation 'ch.qos.logback:logback-classic:1.2.8' + implementation 'ch.qos.logback:logback-core:1.2.8' + implementation 'com.github.maricn:logback-slack-appender:1.4.0' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'mysql:mysql-connector-java' @@ -35,14 +47,63 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' testImplementation 'org.springframework.security:spring-security-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" } +// QueryDsl 설정 +def querydslDir = "$buildDir/generated/querydsl" + +querydsl { + jpa = true + querydslSourcesDir = querydslDir +} + +sourceSets { + main.java.srcDir querydslDir +} + +compileQuerydsl { + options.annotationProcessorPath = configurations.querydsl +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + querydsl.extendsFrom compileClasspath +} +// ----- + test { outputs.dir snippetsDir useJUnitPlatform() + finalizedBy 'jacocoTestReport' } asciidoctor { inputs.dir snippetsDir dependsOn test } + +jacocoTestReport { + finalizedBy 'jacocoTestCoverageVerification' +} + +jacocoTestCoverageVerification { + violationRules { + rule { + enabled = true + element = 'BUNDLE' + + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.50 + } + + excludes = [] + } + } +} diff --git a/settings.gradle b/settings.gradle index 0f5036dc..aa3e0034 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'backend' +rootProject.name = 'surf' diff --git a/src/main/java/org/ahpuh/backend/aop/SoftDelete.java b/src/main/java/org/ahpuh/backend/aop/SoftDelete.java deleted file mode 100644 index 64589a56..00000000 --- a/src/main/java/org/ahpuh/backend/aop/SoftDelete.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.ahpuh.backend.aop; - -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.Where; - -import java.lang.annotation.*; - -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -@Inherited -@Documented -@Where(clause = "is_deleted = false") -@DynamicInsert -public @interface SoftDelete { - -} \ No newline at end of file diff --git a/src/main/java/org/ahpuh/backend/common/response/ApiResponse.java b/src/main/java/org/ahpuh/backend/common/response/ApiResponse.java deleted file mode 100644 index bf525b94..00000000 --- a/src/main/java/org/ahpuh/backend/common/response/ApiResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.ahpuh.backend.common.response; - -import com.fasterxml.jackson.annotation.JsonFormat; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.time.LocalDateTime; - -@Getter -@Setter -@NoArgsConstructor -public class ApiResponse { - - private int statusCode; - private T data; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") - private LocalDateTime serverDatetime; - - public ApiResponse(final int statusCode, final T data) { - this.statusCode = statusCode; - this.data = data; - this.serverDatetime = LocalDateTime.now(); - } - - public static ApiResponse ok(final T data) { - return new ApiResponse<>(200, data); - } - - public static ApiResponse created(final T data) { - return new ApiResponse<>(201, data); - } - - public static ApiResponse fail(final int statusCode, final T errData) { - return new ApiResponse<>(statusCode, errData); - } - -} \ No newline at end of file diff --git a/src/main/java/org/ahpuh/backend/BackendApplication.java b/src/main/java/org/ahpuh/surf/SurfApplication.java similarity index 62% rename from src/main/java/org/ahpuh/backend/BackendApplication.java rename to src/main/java/org/ahpuh/surf/SurfApplication.java index 3f342740..07f2aeda 100644 --- a/src/main/java/org/ahpuh/backend/BackendApplication.java +++ b/src/main/java/org/ahpuh/surf/SurfApplication.java @@ -1,13 +1,13 @@ -package org.ahpuh.backend; +package org.ahpuh.surf; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class BackendApplication { +public class SurfApplication { public static void main(final String[] args) { - SpringApplication.run(BackendApplication.class, args); + SpringApplication.run(SurfApplication.class, args); } } diff --git a/src/main/java/org/ahpuh/surf/category/controller/CategoryController.java b/src/main/java/org/ahpuh/surf/category/controller/CategoryController.java new file mode 100644 index 00000000..0c07cdda --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/controller/CategoryController.java @@ -0,0 +1,66 @@ +package org.ahpuh.surf.category.controller; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.category.dto.CategoryCreateRequestDto; +import org.ahpuh.surf.category.dto.CategoryDetailResponseDto; +import org.ahpuh.surf.category.dto.CategoryResponseDto; +import org.ahpuh.surf.category.dto.CategoryUpdateRequestDto; +import org.ahpuh.surf.category.service.CategoryService; +import org.ahpuh.surf.jwt.JwtAuthentication; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/categories") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @PostMapping + public ResponseEntity createCategory( + @AuthenticationPrincipal final JwtAuthentication authentication, + @Valid @RequestBody final CategoryCreateRequestDto request + ) { + final Long categoryId = categoryService.createCategory(authentication.userId, request); + return ResponseEntity.created(URI.create("/api/v1/categories" + categoryId)).body(categoryId); + } + + @PutMapping("/{categoryId}") + public ResponseEntity updateCategory( + @PathVariable final Long categoryId, + @Valid @RequestBody final CategoryUpdateRequestDto request + ) { + final Long id = categoryService.updateCategory(categoryId, request); + return ResponseEntity.ok().body(id); + } + + @DeleteMapping("/{categoryId}") + public ResponseEntity deleteCategory( + @PathVariable final Long categoryId + ) { + categoryService.deleteCategory(categoryId); + return ResponseEntity.noContent().build(); + } + + @GetMapping + public ResponseEntity> findAllCategoryByUser( + @AuthenticationPrincipal final JwtAuthentication authentication + ) { + final Long userId = authentication.userId; + return ResponseEntity.ok().body(categoryService.findAllCategoryByUser(userId)); + } + + @GetMapping("/dashboard") + public ResponseEntity> getCategoryDashboard( + @RequestParam final Long userId + ) { + return ResponseEntity.ok().body(categoryService.getCategoryDashboard(userId)); + } + +} diff --git a/src/main/java/org/ahpuh/surf/category/converter/CategoryConverter.java b/src/main/java/org/ahpuh/surf/category/converter/CategoryConverter.java new file mode 100644 index 00000000..06cca5e4 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/converter/CategoryConverter.java @@ -0,0 +1,41 @@ +package org.ahpuh.surf.category.converter; + +import org.ahpuh.surf.category.dto.CategoryCreateRequestDto; +import org.ahpuh.surf.category.dto.CategoryDetailResponseDto; +import org.ahpuh.surf.category.dto.CategoryResponseDto; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.user.entity.User; +import org.springframework.stereotype.Component; + +@Component +public class CategoryConverter { + + public Category toEntity(final User user, final CategoryCreateRequestDto createRequestDto) { + return Category.builder() + .user(user) + .name(createRequestDto.getName()) + .colorCode(createRequestDto.getColorCode()) + .build(); + } + + public CategoryResponseDto toCategoryResponseDto(final Category category) { + return CategoryResponseDto.builder() + .categoryId(category.getCategoryId()) + .name(category.getName()) + .isPublic(category.getIsPublic()) + .colorCode(category.getColorCode()) + .build(); + } + + public CategoryDetailResponseDto toCategoryDetailResponseDto(final Category category, int averageScore) { + return CategoryDetailResponseDto.builder() + .categoryId(category.getCategoryId()) + .name(category.getName()) + .averageScore(averageScore) + .isPublic(category.getIsPublic()) + .colorCode(category.getColorCode()) + .postCount(category.getPostCount()) + .build(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/category/dto/CategoryCreateRequestDto.java b/src/main/java/org/ahpuh/surf/category/dto/CategoryCreateRequestDto.java new file mode 100644 index 00000000..3198697d --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/dto/CategoryCreateRequestDto.java @@ -0,0 +1,22 @@ +package org.ahpuh.surf.category.dto; + +import lombok.*; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class CategoryCreateRequestDto { + + @NotBlank(message = "Category name length must be 1 ~ 30.") + @Size(min = 1, max = 30) + private String name; + + @Pattern(regexp = "^#(?:[0-9a-fA-F]{3}){1,2}$", message = "Invalid colorCode.") + private String colorCode; + +} diff --git a/src/main/java/org/ahpuh/surf/category/dto/CategoryDetailResponseDto.java b/src/main/java/org/ahpuh/surf/category/dto/CategoryDetailResponseDto.java new file mode 100644 index 00000000..b419d09e --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/dto/CategoryDetailResponseDto.java @@ -0,0 +1,22 @@ +package org.ahpuh.surf.category.dto; + +import lombok.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class CategoryDetailResponseDto { + + private Long categoryId; + + private String name; + + private int averageScore; + + private Boolean isPublic; + + private String colorCode; + + private int postCount; +} diff --git a/src/main/java/org/ahpuh/surf/category/dto/CategoryResponseDto.java b/src/main/java/org/ahpuh/surf/category/dto/CategoryResponseDto.java new file mode 100644 index 00000000..468c77d5 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/dto/CategoryResponseDto.java @@ -0,0 +1,19 @@ +package org.ahpuh.surf.category.dto; + +import lombok.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class CategoryResponseDto { + + private Long categoryId; + + private String name; + + private Boolean isPublic; + + private String colorCode; + +} diff --git a/src/main/java/org/ahpuh/surf/category/dto/CategorySimpleDto.java b/src/main/java/org/ahpuh/surf/category/dto/CategorySimpleDto.java new file mode 100644 index 00000000..27e7fb54 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/dto/CategorySimpleDto.java @@ -0,0 +1,32 @@ +package org.ahpuh.surf.category.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.ahpuh.surf.post.dto.PostScoreDto; + +import java.util.List; + +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CategorySimpleDto { + + private Long categoryId; + private String categoryName; + private String colorCode; + private List postScores; + + @QueryProjection + public CategorySimpleDto(final Long categoryId, + final String categoryName, + final String colorCode, + final List postScores) { + this.categoryId = categoryId; + this.categoryName = categoryName; + this.colorCode = colorCode; + this.postScores = postScores; + } +} diff --git a/src/main/java/org/ahpuh/surf/category/dto/CategoryUpdateRequestDto.java b/src/main/java/org/ahpuh/surf/category/dto/CategoryUpdateRequestDto.java new file mode 100644 index 00000000..46a4168f --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/dto/CategoryUpdateRequestDto.java @@ -0,0 +1,26 @@ +package org.ahpuh.surf.category.dto; + +import lombok.*; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class CategoryUpdateRequestDto { + + @NotBlank(message = "Category name length must be 1 ~ 30.") + @Size(min = 1, max = 30) + private String name; + + @NotNull(message = "No is_public input.") + private Boolean isPublic; + + @Pattern(regexp = "^#(?:[0-9a-fA-F]{3}){1,2}$", message = "Invalid colorCode.") + private String colorCode; + +} diff --git a/src/main/java/org/ahpuh/surf/category/entity/Category.java b/src/main/java/org/ahpuh/surf/category/entity/Category.java new file mode 100644 index 00000000..e393f16e --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/entity/Category.java @@ -0,0 +1,70 @@ +package org.ahpuh.surf.category.entity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.ahpuh.surf.common.entity.BaseEntity; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.user.entity.User; +import org.hibernate.annotations.Formula; +import org.hibernate.annotations.Where; + +import javax.persistence.*; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "categories") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Where(clause = "is_deleted = false") +public class Category extends BaseEntity { + + @Id + @Column(name = "category_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long categoryId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "is_public", nullable = false) + @Builder.Default + private Boolean isPublic = true; + + @Column(name = "color_code") + private String colorCode; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "user_id") + private User user; + + @OneToMany(mappedBy = "category", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List posts = new ArrayList<>(); + + @Formula("(select count(1) from posts p where p.category_id = category_id and p.is_deleted = false)") + private int postCount; + + @Builder + public Category(final User user, final String name, final String colorCode) { + this.user = user; + this.name = name; + this.colorCode = colorCode; + user.addCategory(this); + } + + public void addPost(final Post post) { + posts.add(post); + } + + public void update(final String name, final boolean isPublic, final String colorCode) { + this.name = name; + this.isPublic = isPublic; + this.colorCode = colorCode; + } + +} diff --git a/src/main/java/org/ahpuh/surf/category/repository/CategoryRepository.java b/src/main/java/org/ahpuh/surf/category/repository/CategoryRepository.java new file mode 100644 index 00000000..2a9b5159 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/repository/CategoryRepository.java @@ -0,0 +1,11 @@ +package org.ahpuh.surf.category.repository; + +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CategoryRepository extends JpaRepository { + List findByUser(User user); +} diff --git a/src/main/java/org/ahpuh/surf/category/service/CategoryService.java b/src/main/java/org/ahpuh/surf/category/service/CategoryService.java new file mode 100644 index 00000000..bb5993a3 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/service/CategoryService.java @@ -0,0 +1,22 @@ +package org.ahpuh.surf.category.service; + +import org.ahpuh.surf.category.dto.CategoryCreateRequestDto; +import org.ahpuh.surf.category.dto.CategoryDetailResponseDto; +import org.ahpuh.surf.category.dto.CategoryResponseDto; +import org.ahpuh.surf.category.dto.CategoryUpdateRequestDto; + +import java.util.List; + +public interface CategoryService { + + Long createCategory(Long userId, CategoryCreateRequestDto categoryDto); + + Long updateCategory(Long categoryId, CategoryUpdateRequestDto categoryDto); + + void deleteCategory(Long categoryId); + + List findAllCategoryByUser(Long userId); + + List getCategoryDashboard(Long userId); + +} diff --git a/src/main/java/org/ahpuh/surf/category/service/CategoryServiceImpl.java b/src/main/java/org/ahpuh/surf/category/service/CategoryServiceImpl.java new file mode 100644 index 00000000..6ef51d57 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/category/service/CategoryServiceImpl.java @@ -0,0 +1,92 @@ +package org.ahpuh.surf.category.service; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.category.converter.CategoryConverter; +import org.ahpuh.surf.category.dto.CategoryCreateRequestDto; +import org.ahpuh.surf.category.dto.CategoryDetailResponseDto; +import org.ahpuh.surf.category.dto.CategoryResponseDto; +import org.ahpuh.surf.category.dto.CategoryUpdateRequestDto; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.common.entity.BaseEntity; +import org.ahpuh.surf.common.exception.EntityExceptionHandler; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryServiceImpl implements CategoryService { + + private final CategoryRepository categoryRepository; + + private final PostRepository postRepository; + + private final UserRepository userRepository; + + private final CategoryConverter categoryConverter; + + @Override + @Transactional + public Long createCategory(final Long userId, final CategoryCreateRequestDto categoryDto) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + final Category category = categoryConverter.toEntity(user, categoryDto); + + return categoryRepository.save(category).getCategoryId(); + } + + @Override + @Transactional + public Long updateCategory(final Long categoryId, final CategoryUpdateRequestDto categoryDto) { + final Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> EntityExceptionHandler.CategoryNotFound(categoryId)); + category.update(categoryDto.getName(), categoryDto.getIsPublic(), categoryDto.getColorCode()); + + return category.getCategoryId(); + } + + @Override + @Transactional + public void deleteCategory(final Long categoryId) { + final Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> EntityExceptionHandler.CategoryNotFound(categoryId)); + category.delete(); + category.getPosts() + .forEach(BaseEntity::delete); + } + + @Override + public List findAllCategoryByUser(final Long userId) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + final List categoryList = categoryRepository.findByUser(user); + + return categoryList.stream() + .map(categoryConverter::toCategoryResponseDto) + .toList(); + } + + @Override + public List getCategoryDashboard(final Long userId) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + final List categoryList = categoryRepository.findByUser(user); + + return categoryList.stream() + .map((Category category) -> categoryConverter.toCategoryDetailResponseDto(category, (int) getAverageScore(category))) + .toList(); + } + + private double getAverageScore(final Category category) { + return postRepository.findByCategory(category).stream() + .mapToInt(Post::getScore) + .average().orElse(0); + } +} diff --git a/src/main/java/org/ahpuh/backend/common/entity/BaseEntity.java b/src/main/java/org/ahpuh/surf/common/entity/BaseEntity.java similarity index 68% rename from src/main/java/org/ahpuh/backend/common/entity/BaseEntity.java rename to src/main/java/org/ahpuh/surf/common/entity/BaseEntity.java index 126b1943..cc843a22 100644 --- a/src/main/java/org/ahpuh/backend/common/entity/BaseEntity.java +++ b/src/main/java/org/ahpuh/surf/common/entity/BaseEntity.java @@ -1,9 +1,6 @@ -package org.ahpuh.backend.common.entity; +package org.ahpuh.surf.common.entity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import lombok.experimental.SuperBuilder; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; @@ -12,6 +9,7 @@ import javax.persistence.Column; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; +import java.time.LocalDateTime; @Getter @MappedSuperclass @@ -19,21 +17,22 @@ @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor -public class BaseEntity { +public abstract class BaseEntity { @CreatedDate @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @LastModifiedDate - @Column(name = "modified_at") - private LocalDateTime modifiedAt; + @Column(name = "updated_at") + private LocalDateTime updatedAt; @Column(name = "is_deleted", columnDefinition = "boolean default false") + @Builder.Default private Boolean isDeleted = false; - public void setIsDeleted(final Boolean deleted) { - this.isDeleted = deleted; + public void delete() { + this.isDeleted = true; } } diff --git a/src/main/java/org/ahpuh/surf/common/exception/EntityExceptionHandler.java b/src/main/java/org/ahpuh/surf/common/exception/EntityExceptionHandler.java new file mode 100644 index 00000000..d12aca22 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/common/exception/EntityExceptionHandler.java @@ -0,0 +1,40 @@ +package org.ahpuh.surf.common.exception; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.text.MessageFormat; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EntityExceptionHandler { + + public static IllegalArgumentException CategoryNotFound(final Long categoryId) { + return new IllegalArgumentException("Category with given id not found. Invalid id is " + categoryId); + } + + public static IllegalArgumentException UserNotFound(final Long userId) { + return new IllegalArgumentException("User with given id not found. Invalid id is " + userId); + } + + public static IllegalArgumentException UserNotFound(final String email) { + return new IllegalArgumentException("User with given email not found. Invalid email is " + email); + } + + public static IllegalArgumentException PostNotFound(final Long postId) { + return new IllegalArgumentException("Post with given id not found. Invalid id is " + postId); + } + + public static IllegalArgumentException FollowNotFound() { + return new IllegalArgumentException("삭제하려는 팔로우 기록이 없습니다."); + } + + public static IllegalArgumentException FollowingNotFound() { + return new IllegalArgumentException("삭제하려는 팔로우 기록이 없습니다."); + } + + public static IllegalArgumentException UserNotMatching(final Long userId, final Long requestUserId) { + return new IllegalArgumentException( + MessageFormat.format("로그인한 회원 id {0}와 요청한 회원의 id {1}가 일치하지 않습니다.", userId, requestUserId) + ); + } +} diff --git a/src/main/java/org/ahpuh/surf/common/response/CursorResult.java b/src/main/java/org/ahpuh/surf/common/response/CursorResult.java new file mode 100644 index 00000000..4865b6ed --- /dev/null +++ b/src/main/java/org/ahpuh/surf/common/response/CursorResult.java @@ -0,0 +1,6 @@ +package org.ahpuh.surf.common.response; + +import java.util.List; + +public record CursorResult(List values, Boolean hasNext) { +} diff --git a/src/main/java/org/ahpuh/surf/common/s3/S3Service.java b/src/main/java/org/ahpuh/surf/common/s3/S3Service.java new file mode 100644 index 00000000..24452789 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/common/s3/S3Service.java @@ -0,0 +1,23 @@ +package org.ahpuh.surf.common.s3; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +public interface S3Service { + + String uploadUserImg(final MultipartFile profilePhoto) throws IOException; + + S3ServiceImpl.FileStatus uploadPostFile(final MultipartFile file) throws IOException; + + String uploadImg(final MultipartFile file) throws IOException; + + String uploadFile(final MultipartFile file) throws IOException; + + boolean exist(final MultipartFile file); + + boolean invalidImageExtension(final String extension); + + boolean invalidFileExtension(final String extension); + +} diff --git a/src/main/java/org/ahpuh/surf/common/s3/S3ServiceImpl.java b/src/main/java/org/ahpuh/surf/common/s3/S3ServiceImpl.java new file mode 100644 index 00000000..d0f55fbe --- /dev/null +++ b/src/main/java/org/ahpuh/surf/common/s3/S3ServiceImpl.java @@ -0,0 +1,135 @@ +package org.ahpuh.surf.common.s3; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.PutObjectRequest; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.util.Objects; + +@Service +@NoArgsConstructor +@Slf4j +public class S3ServiceImpl implements S3Service { + + final String[] PERMISSION_IMG_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "tif", "ico", "svg", "bmp", "webp", "tiff", "jfif"}; + final String[] PERMISSION_FILE_EXTENSIONS = {"doc", "docx", "xls", "xlsx", "hwp", "pdf", "txt", "md", "ppt", "pptx", "key"}; + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.region.static}") + private String region; + + private AmazonS3 s3Client; + + @PostConstruct + public void setS3Client() { + final AWSCredentials credentials = new BasicAWSCredentials(this.accessKey, this.secretKey); + + s3Client = AmazonS3ClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(this.region) + .build(); + } + + public String uploadUserImg(final MultipartFile profilePhoto) throws IOException { + if (exist(profilePhoto)) { + return uploadImg(profilePhoto); + } + return null; + } + + public FileStatus uploadPostFile(final MultipartFile file) throws IOException { + if (exist(file)) { + String fileUrl = uploadFile(file); + if (fileUrl != null) { + return new FileStatus(fileUrl, "file"); + } + + fileUrl = uploadImg(file); + if (fileUrl != null) { + return new FileStatus(fileUrl, "img"); + } + } + return null; + } + + public String uploadImg(final MultipartFile file) throws IOException { + final String fileName = file.getOriginalFilename(); + final String extension = Objects.requireNonNull(fileName).split("\\.")[1]; + + if (invalidImageExtension(extension)) { + log.info("{}은(는) 지원하지 않는 확장자입니다.", extension); + return null; + } + + s3Client.putObject(new PutObjectRequest(bucket, fileName, file.getInputStream(), null) + .withCannedAcl(CannedAccessControlList.PublicRead)); + + return s3Client.getUrl(bucket, fileName).toString(); + } + + public String uploadFile(final MultipartFile file) throws IOException { + final String fileName = file.getOriginalFilename(); + final String extension = Objects.requireNonNull(fileName).split("\\.")[1]; + + if (invalidFileExtension(extension)) { + return null; + } + + s3Client.putObject(new PutObjectRequest(bucket, fileName, file.getInputStream(), null) + .withCannedAcl(CannedAccessControlList.PublicRead)); + + return s3Client.getUrl(bucket, fileName).toString(); + } + + public boolean exist(final MultipartFile file) { + return !file.isEmpty(); + } + + public boolean invalidImageExtension(final String extension) { + for (final String permissionExtension : PERMISSION_IMG_EXTENSIONS) { + if (extension.equals(permissionExtension)) { + return false; + } + } + return true; + } + + public boolean invalidFileExtension(final String extension) { + for (final String permissionExtension : PERMISSION_FILE_EXTENSIONS) { + if (extension.equals(permissionExtension)) { + return false; + } + } + return true; + } + + public static class FileStatus { + public String fileUrl; + public String fileType; + + public FileStatus(final String fileUrl, final String fileType) { + this.fileUrl = fileUrl; + this.fileType = fileType; + } + } + +} diff --git a/src/main/java/org/ahpuh/backend/config/auditing/JpaAuditing.java b/src/main/java/org/ahpuh/surf/config/JpaAuditing.java similarity index 82% rename from src/main/java/org/ahpuh/backend/config/auditing/JpaAuditing.java rename to src/main/java/org/ahpuh/surf/config/JpaAuditing.java index 09df8f47..27b23f41 100644 --- a/src/main/java/org/ahpuh/backend/config/auditing/JpaAuditing.java +++ b/src/main/java/org/ahpuh/surf/config/JpaAuditing.java @@ -1,4 +1,4 @@ -package org.ahpuh.backend.config.auditing; +package org.ahpuh.surf.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/src/main/java/org/ahpuh/surf/config/JwtConfig.java b/src/main/java/org/ahpuh/surf/config/JwtConfig.java new file mode 100644 index 00000000..21fdadbc --- /dev/null +++ b/src/main/java/org/ahpuh/surf/config/JwtConfig.java @@ -0,0 +1,22 @@ +package org.ahpuh.surf.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@Getter +@Setter +@ConfigurationProperties(prefix = "jwt") +public class JwtConfig { + + private String header; + + private String issuer; + + private String clientSecret; + + private int expirySeconds; + +} diff --git a/src/main/java/org/ahpuh/surf/config/QuerydslConfig.java b/src/main/java/org/ahpuh/surf/config/QuerydslConfig.java new file mode 100644 index 00000000..9f2c0978 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/config/QuerydslConfig.java @@ -0,0 +1,21 @@ +package org.ahpuh.surf.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } + +} diff --git a/src/main/java/org/ahpuh/surf/config/ReadOnlyMultipartFormDataEndpointConverter.java b/src/main/java/org/ahpuh/surf/config/ReadOnlyMultipartFormDataEndpointConverter.java new file mode 100644 index 00000000..c27a37f8 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/config/ReadOnlyMultipartFormDataEndpointConverter.java @@ -0,0 +1,50 @@ +package org.ahpuh.surf.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerMapping; + +import java.lang.reflect.Type; + +public class ReadOnlyMultipartFormDataEndpointConverter extends MappingJackson2HttpMessageConverter { + + public ReadOnlyMultipartFormDataEndpointConverter(final ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + public boolean canRead(final Type type, final Class contextClass, final MediaType mediaType) { + // When a rest client(e.g. RestTemplate#getForObject) reads a request, 'RequestAttributes' can be null. + final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return false; + } + final HandlerMethod handlerMethod = (HandlerMethod) requestAttributes + .getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); + if (handlerMethod == null) { + return false; + } + final RequestMapping requestMapping = handlerMethod.getMethodAnnotation(RequestMapping.class); + if (requestMapping == null) { + return false; + } + // This converter reads data only when the mapped controller method consumes just 'MediaType.MULTIPART_FORM_DATA_VALUE'. + if (requestMapping.consumes().length != 1 + || !MediaType.MULTIPART_FORM_DATA_VALUE.equals(requestMapping.consumes()[0])) { + return false; + } + return super.canRead(type, contextClass, mediaType); + } + + @Override + protected boolean canWrite(final MediaType mediaType) { + // This converter is only be used for requests. + return false; + } + +} diff --git a/src/main/java/org/ahpuh/surf/config/WebMvcConfig.java b/src/main/java/org/ahpuh/surf/config/WebMvcConfig.java new file mode 100644 index 00000000..ff5a0284 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/config/WebMvcConfig.java @@ -0,0 +1,51 @@ +package org.ahpuh.surf.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.ArrayList; +import java.util.List; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Autowired + private ObjectMapper objectMapper; + + @Override + public void extendMessageConverters(final List> converters) { + final ReadOnlyMultipartFormDataEndpointConverter converter = new ReadOnlyMultipartFormDataEndpointConverter(objectMapper); + final List supportedMediaTypes = new ArrayList<>(); + supportedMediaTypes.addAll(converter.getSupportedMediaTypes()); + supportedMediaTypes.add(MediaType.APPLICATION_OCTET_STREAM); + converter.setSupportedMediaTypes(supportedMediaTypes); + + converters.add(converter); + } + + @Override + public void addCorsMappings(final CorsRegistry registry) { + registry + .addMapping("/**") + .allowedOrigins( + "https://surf-livid.vercel.app", + "http://localhost:3000") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600) + .allowedMethods( + HttpMethod.POST.name(), + HttpMethod.GET.name(), + HttpMethod.DELETE.name(), + HttpMethod.PATCH.name(), + HttpMethod.PUT.name(), + HttpMethod.OPTIONS.name()); + } + +} diff --git a/src/main/java/org/ahpuh/surf/config/WebSecurityConfig.java b/src/main/java/org/ahpuh/surf/config/WebSecurityConfig.java new file mode 100644 index 00000000..328cfda5 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/config/WebSecurityConfig.java @@ -0,0 +1,114 @@ +package org.ahpuh.surf.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ahpuh.surf.jwt.Jwt; +import org.ahpuh.surf.jwt.JwtAuthenticationFilter; +import org.ahpuh.surf.jwt.JwtAuthenticationProvider; +import org.ahpuh.surf.user.service.UserService; +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.context.SecurityContextPersistenceFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import javax.servlet.http.HttpServletResponse; + +@EnableWebSecurity +@RequiredArgsConstructor +@Slf4j +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + private final JwtConfig jwtConfig; + + @Override + public void configure(final WebSecurity web) { + web.ignoring().antMatchers("/assets/**", "/h2-console/**"); + } + + @Bean + public AccessDeniedHandler accessDeniedHandler() { + return (request, response, e) -> { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + final Object principal = authentication != null ? authentication.getPrincipal() : null; + log.error("{} is denied", principal, e); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("text/plain;charset=UTF-8"); + response.getWriter().write("ACCESS DENIED"); + response.getWriter().flush(); + response.getWriter().close(); + }; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public Jwt jwt() { + return new Jwt( + jwtConfig.getIssuer(), + jwtConfig.getClientSecret(), + jwtConfig.getExpirySeconds() + ); + } + + @Bean + public JwtAuthenticationProvider jwtAuthenticationProvider(final UserService userService, final Jwt jwt) { + return new JwtAuthenticationProvider(jwt, userService); + } + + @Bean + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } + + public JwtAuthenticationFilter jwtAuthenticationFilter() { + final Jwt jwt = getApplicationContext().getBean(Jwt.class); + return new JwtAuthenticationFilter(jwtConfig.getHeader(), jwt); + } + + @Override + protected void configure(final HttpSecurity http) throws Exception { + http + .authorizeRequests() + .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() + .anyRequest().permitAll() +// .anyRequest().authenticated() + .and() + .headers() + .disable() + .csrf() + .disable() + .formLogin() + .disable() + .httpBasic() + .disable() + .rememberMe() + .disable() + .logout() + .disable() + .exceptionHandling() + .accessDeniedHandler(accessDeniedHandler()) + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .addFilterAfter(jwtAuthenticationFilter(), SecurityContextPersistenceFilter.class) + ; + } +} diff --git a/src/main/java/org/ahpuh/surf/follow/controller/FollowController.java b/src/main/java/org/ahpuh/surf/follow/controller/FollowController.java new file mode 100644 index 00000000..214cdc31 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/follow/controller/FollowController.java @@ -0,0 +1,56 @@ +package org.ahpuh.surf.follow.controller; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.follow.dto.FollowUserDto; +import org.ahpuh.surf.follow.service.FollowService; +import org.ahpuh.surf.jwt.JwtAuthentication; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class FollowController { + + private final FollowService followService; + + @PostMapping("/follow") + public ResponseEntity follow( + @AuthenticationPrincipal final JwtAuthentication authentication, + @RequestBody final Long followUserId + ) { + final Long followId = followService.follow(authentication.userId, followUserId); + return ResponseEntity.created(URI.create("/api/v1/users/" + authentication.userId + "/following")) + .body(followId); + } + + @DeleteMapping("/follow/{userId}") + public ResponseEntity unfollow( + @AuthenticationPrincipal final JwtAuthentication authentication, + @PathVariable final Long userId + ) { + followService.unfollow(authentication.userId, userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/users/{userId}/followers") + public ResponseEntity> findFollowersList( + @PathVariable final Long userId + ) { + final List response = followService.findFollowerList(userId); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/users/{userId}/following") + public ResponseEntity> findFollowingList( + @PathVariable final Long userId + ) { + final List response = followService.findFollowingList(userId); + return ResponseEntity.ok().body(response); + } + +} diff --git a/src/main/java/org/ahpuh/surf/follow/converter/FollowConverter.java b/src/main/java/org/ahpuh/surf/follow/converter/FollowConverter.java new file mode 100644 index 00000000..27e5fae5 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/follow/converter/FollowConverter.java @@ -0,0 +1,26 @@ +package org.ahpuh.surf.follow.converter; + +import org.ahpuh.surf.follow.dto.FollowUserDto; +import org.ahpuh.surf.follow.entity.Follow; +import org.ahpuh.surf.user.entity.User; +import org.springframework.stereotype.Component; + +@Component +public class FollowConverter { + + public Follow toEntity(final User user, final User followedUser) { + return Follow.builder() + .user(user) + .followedUser(followedUser) + .build(); + } + + public FollowUserDto toFollowUserDto(final User user) { + return FollowUserDto.builder() + .userId(user.getUserId()) + .userName(user.getUserName()) + .profilePhotoUrl(user.getProfilePhotoUrl()) + .build(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/follow/dto/FollowUserDto.java b/src/main/java/org/ahpuh/surf/follow/dto/FollowUserDto.java new file mode 100644 index 00000000..abd931f5 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/follow/dto/FollowUserDto.java @@ -0,0 +1,17 @@ +package org.ahpuh.surf.follow.dto; + +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class FollowUserDto { + + private Long userId; + + private String userName; + + private String profilePhotoUrl; + +} diff --git a/src/main/java/org/ahpuh/surf/follow/entity/Follow.java b/src/main/java/org/ahpuh/surf/follow/entity/Follow.java new file mode 100644 index 00000000..daf4c2f2 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/follow/entity/Follow.java @@ -0,0 +1,43 @@ +package org.ahpuh.surf.follow.entity; + +import lombok.*; +import org.ahpuh.surf.user.entity.User; + +import javax.persistence.*; + +@Entity +@Table( + name = "follow", + uniqueConstraints = { + @UniqueConstraint( + columnNames = {"user_id", "following_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Follow { + + @Id + @Column(name = "follow_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long followId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "following_id", referencedColumnName = "user_id") + private User followedUser; + + @Builder + public Follow(final User user, final User followedUser) { + this.user = user; + this.followedUser = followedUser; + user.addFollowing(this); + followedUser.addFollowers(this); + } + +} diff --git a/src/main/java/org/ahpuh/surf/follow/repository/FollowRepository.java b/src/main/java/org/ahpuh/surf/follow/repository/FollowRepository.java new file mode 100644 index 00000000..a4ac97ae --- /dev/null +++ b/src/main/java/org/ahpuh/surf/follow/repository/FollowRepository.java @@ -0,0 +1,22 @@ +package org.ahpuh.surf.follow.repository; + +import org.ahpuh.surf.follow.entity.Follow; +import org.ahpuh.surf.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FollowRepository extends JpaRepository { + + Optional findByUserAndFollowedUser(User me, User followedUser); + + List findByUser(User user); + + List findByFollowedUser(User user); + + long countByUser(User user); + + long countByFollowedUser(User user); + +} diff --git a/src/main/java/org/ahpuh/surf/follow/service/FollowService.java b/src/main/java/org/ahpuh/surf/follow/service/FollowService.java new file mode 100644 index 00000000..610bcd96 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/follow/service/FollowService.java @@ -0,0 +1,17 @@ +package org.ahpuh.surf.follow.service; + +import org.ahpuh.surf.follow.dto.FollowUserDto; + +import java.util.List; + +public interface FollowService { + + Long follow(Long userId, Long followUserId); + + void unfollow(Long myId, Long userId); + + List findFollowerList(Long userId); + + List findFollowingList(Long userId); + +} diff --git a/src/main/java/org/ahpuh/surf/follow/service/FollowServiceImpl.java b/src/main/java/org/ahpuh/surf/follow/service/FollowServiceImpl.java new file mode 100644 index 00000000..1f3babfd --- /dev/null +++ b/src/main/java/org/ahpuh/surf/follow/service/FollowServiceImpl.java @@ -0,0 +1,77 @@ +package org.ahpuh.surf.follow.service; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.common.exception.EntityExceptionHandler; +import org.ahpuh.surf.follow.converter.FollowConverter; +import org.ahpuh.surf.follow.dto.FollowUserDto; +import org.ahpuh.surf.follow.entity.Follow; +import org.ahpuh.surf.follow.repository.FollowRepository; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.ahpuh.surf.common.exception.EntityExceptionHandler.UserNotFound; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FollowServiceImpl implements FollowService { + + private final FollowRepository followRepository; + + private final FollowConverter followConverter; + + private final UserRepository userRepository; + + @Override + @Transactional + public Long follow(final Long userId, final Long followUserId) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> UserNotFound(userId)); + final User followedUser = userRepository.findById(followUserId) + .orElseThrow(() -> UserNotFound(followUserId)); + + return followRepository.save(followConverter.toEntity(user, followedUser)) + .getFollowId(); + } + + @Override + @Transactional + public void unfollow(final Long myId, final Long userId) { + final User me = userRepository.findById(myId) + .orElseThrow(() -> UserNotFound(myId)); + final User followedUser = userRepository.findById(userId) + .orElseThrow(() -> UserNotFound(userId)); + + final Follow followEntity = followRepository.findByUserAndFollowedUser(me, followedUser) + .orElseThrow(EntityExceptionHandler::FollowNotFound); + + followRepository.delete(followEntity); + } + + @Override + public List findFollowerList(final Long userId) { + final User userEntity = userRepository.findById(userId) + .orElseThrow(() -> UserNotFound(userId)); + return followRepository.findByFollowedUser(userEntity) + .stream() + .map(Follow::getUser) + .map(followConverter::toFollowUserDto) + .toList(); + } + + @Override + public List findFollowingList(final Long userId) { + final User userEntity = userRepository.findById(userId) + .orElseThrow(() -> UserNotFound(userId)); + return followRepository.findByUser(userEntity) + .stream() + .map(Follow::getFollowedUser) + .map(followConverter::toFollowUserDto) + .toList(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/jwt/Claims.java b/src/main/java/org/ahpuh/surf/jwt/Claims.java new file mode 100644 index 00000000..2c722cac --- /dev/null +++ b/src/main/java/org/ahpuh/surf/jwt/Claims.java @@ -0,0 +1,51 @@ +package org.ahpuh.surf.jwt; + +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class Claims { + + public Long userId; + + public String email; + + public String[] roles; + + public Date iat; + + public Date exp; + + public Claims(final DecodedJWT decodedJWT) { + final Claim userId = decodedJWT.getClaim("user_id"); + if (!userId.isNull()) { + this.userId = userId.asLong(); + } + + final Claim email = decodedJWT.getClaim("email"); + if (!email.isNull()) { + this.email = email.asString(); + } + + final Claim roles = decodedJWT.getClaim("roles"); + if (!roles.isNull()) { + this.roles = roles.asArray(String.class); + } + + this.iat = decodedJWT.getIssuedAt(); + this.exp = decodedJWT.getExpiresAt(); + } + + public static Claims from(final Long userId, final String email, final String[] roles) { + final Claims claims = new Claims(); + claims.userId = userId; + claims.email = email; + claims.roles = roles; + return claims; + } + +} diff --git a/src/main/java/org/ahpuh/surf/jwt/Jwt.java b/src/main/java/org/ahpuh/surf/jwt/Jwt.java new file mode 100644 index 00000000..a7a359fc --- /dev/null +++ b/src/main/java/org/ahpuh/surf/jwt/Jwt.java @@ -0,0 +1,52 @@ +package org.ahpuh.surf.jwt; + +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import lombok.Getter; + +import java.util.Date; + +@Getter +public class Jwt { + + private final String issuer; + + private final String clientSecret; + + private final int expirySeconds; + + private final Algorithm algorithm; + + private final JWTVerifier jwtVerifier; + + public Jwt(final String issuer, final String clientSecret, final int expirySeconds) { + this.issuer = issuer; + this.clientSecret = clientSecret; + this.expirySeconds = expirySeconds; + this.algorithm = Algorithm.HMAC512(clientSecret); + this.jwtVerifier = com.auth0.jwt.JWT.require(algorithm) + .withIssuer(issuer) + .build(); + } + + public String sign(final Claims claims) { + final Date now = new Date(); + final JWTCreator.Builder builder = com.auth0.jwt.JWT.create(); + builder.withIssuer(issuer); + builder.withIssuedAt(now); + if (expirySeconds > 0) { + builder.withExpiresAt(new Date(now.getTime() + expirySeconds * 1_000L)); + } + builder.withClaim("user_id", claims.userId); + builder.withClaim("email", claims.email); + builder.withArrayClaim("roles", claims.roles); + return builder.sign(algorithm); + } + + public Claims verify(final String token) throws JWTVerificationException { + return new Claims(jwtVerifier.verify(token)); + } + +} diff --git a/src/main/java/org/ahpuh/surf/jwt/JwtAuthentication.java b/src/main/java/org/ahpuh/surf/jwt/JwtAuthentication.java new file mode 100644 index 00000000..df3ce8da --- /dev/null +++ b/src/main/java/org/ahpuh/surf/jwt/JwtAuthentication.java @@ -0,0 +1,24 @@ +package org.ahpuh.surf.jwt; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; + +public class JwtAuthentication { + + public final String token; + + public final Long userId; + + public final String email; + + public JwtAuthentication(final String token, final Long userId, final String email) { + checkArgument(isNotEmpty(token), "token must be provided."); + checkArgument(userId != null, "userId must be provided."); + checkArgument(isNotEmpty(email), "email must be provided."); + + this.token = token; + this.userId = userId; + this.email = email; + } + +} diff --git a/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationFilter.java b/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..240eddbd --- /dev/null +++ b/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,99 @@ +package org.ahpuh.surf.jwt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.Arrays; +import java.util.List; + +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends GenericFilterBean { + + private final String headerKey; + + private final Jwt jwt; + + @Override + public void doFilter(final ServletRequest req, final ServletResponse res, final FilterChain chain) throws IOException, ServletException { + /** + * HTTP 요청 헤더에 JWT 토큰이 있는지 확인 + * JWT 토큰이 있다면, 주어진 토큰 디코딩 + * userId, email, roles 데이터 추출 + * JwtAuthenticationToken 생성해서 SecurityContext에 넣는다. + **/ + final HttpServletRequest request = (HttpServletRequest) req; + final HttpServletResponse response = (HttpServletResponse) res; + + if (SecurityContextHolder.getContext().getAuthentication() == null) { + final String token = getToken(request); + if (token != null) { + try { + final Claims claims = verify(token); + log.debug("Jwt parse result: {}", claims); + + final Long userId = claims.userId; + final String email = claims.email; + final List authorities = getAuthorities(claims); + + if (userId != null && isNotEmpty(email) && authorities.size() > 0) { + final JwtAuthenticationToken authentication = + new JwtAuthenticationToken(new JwtAuthentication(token, userId, email), null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (final Exception e) { + log.warn("Jwt processing failed: {}", e.getMessage()); + } + } + } else { + log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'", + SecurityContextHolder.getContext().getAuthentication()); + } + + chain.doFilter(request, response); + } + + private String getToken(final HttpServletRequest request) { + final String token = request.getHeader(headerKey); + if (isNotEmpty(token)) { + log.debug("Jwt authorization api detected: {}", token); + try { + return URLDecoder.decode(token, "UTF-8"); + } catch (final UnsupportedEncodingException e) { + log.error(e.getMessage(), e); + } + } + return null; + } + + private Claims verify(final String token) { + return jwt.verify(token); + } + + private List getAuthorities(final Claims claims) { + final String[] roles = claims.roles; + return roles == null || roles.length == 0 + ? emptyList() + : Arrays.stream(roles).map(SimpleGrantedAuthority::new).collect(toList()); + } + +} diff --git a/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationProvider.java b/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationProvider.java new file mode 100644 index 00000000..c19233e4 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationProvider.java @@ -0,0 +1,63 @@ +package org.ahpuh.surf.jwt; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.service.UserService; +import org.springframework.dao.DataAccessException; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +import static org.apache.commons.lang3.ClassUtils.isAssignable; + +@RequiredArgsConstructor +public class JwtAuthenticationProvider implements AuthenticationProvider { + + private final Jwt jwt; + + private final UserService userService; + + @Override + public boolean supports(final Class authentication) { + return isAssignable(JwtAuthenticationToken.class, authentication); + } + + @Override + public Authentication authenticate(final Authentication authentication) throws AuthenticationException { + final JwtAuthenticationToken jwtAuthentication = (JwtAuthenticationToken) authentication; + return processUserAuthentication( + String.valueOf(jwtAuthentication.getPrincipal()), + jwtAuthentication.getCredentials() + ); + } + + private Authentication processUserAuthentication(final String principal, final String credentials) { + try { + final User user = userService.login(principal, credentials); + final List authorities = List.of(new SimpleGrantedAuthority(user.getPermission().getRole())); + final String token = getToken(user.getUserId(), user.getEmail(), authorities); + final JwtAuthenticationToken authenticated = + new JwtAuthenticationToken(new JwtAuthentication(token, user.getUserId(), user.getEmail()), null, authorities); + authenticated.setDetails(user); + return authenticated; + } catch (final IllegalArgumentException e) { + throw new BadCredentialsException(e.getMessage()); + } catch (final DataAccessException e) { + throw new AuthenticationServiceException(e.getMessage(), e); + } + } + + private String getToken(final Long userId, final String email, final List authorities) { + final String[] roles = authorities.stream() + .map(GrantedAuthority::getAuthority) + .toArray(String[]::new); + return jwt.sign(Claims.from(userId, email, roles)); + } + +} diff --git a/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationToken.java b/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationToken.java new file mode 100644 index 00000000..791a727b --- /dev/null +++ b/src/main/java/org/ahpuh/surf/jwt/JwtAuthenticationToken.java @@ -0,0 +1,54 @@ +package org.ahpuh.surf.jwt; + +import lombok.Getter; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +@Getter +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private final Object principal; + + private String credentials; + + public JwtAuthenticationToken(final String principal, final String credentials) { + super(null); + super.setAuthenticated(false); + + this.principal = principal; + this.credentials = credentials; + } + + public JwtAuthenticationToken(final Object principal, final String credentials, final Collection authorities) { + super(authorities); + super.setAuthenticated(true); + + this.principal = principal; + this.credentials = credentials; + } + + public void setAuthenticated(final boolean isAuthenticated) throws IllegalArgumentException { + if (isAuthenticated) { + throw new IllegalArgumentException("Cannot set this token to trusted"); + } + super.setAuthenticated(false); + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + credentials = null; + } + + @Override + public String toString() { + return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) + .append("principal", principal) + .append("credentials", "[PROTECTED]") + .toString(); + } +} diff --git a/src/main/java/org/ahpuh/surf/like/controller/LikeController.java b/src/main/java/org/ahpuh/surf/like/controller/LikeController.java new file mode 100644 index 00000000..5b4e7d65 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/like/controller/LikeController.java @@ -0,0 +1,35 @@ +package org.ahpuh.surf.like.controller; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.jwt.JwtAuthentication; +import org.ahpuh.surf.like.service.LikeService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/posts/{postId}") +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + + @PostMapping("/like") + public ResponseEntity like( + @AuthenticationPrincipal final JwtAuthentication authentication, + @PathVariable final Long postId + ) { + final Long likeId = likeService.like(authentication.userId, postId); + return ResponseEntity.ok().body(likeId); + } + + @DeleteMapping("/unlike/{likeId}") + public ResponseEntity unlike( + @PathVariable final Long postId, + @PathVariable final Long likeId + ) { + likeService.unlike(postId, likeId); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/like/converter/LikeConverter.java b/src/main/java/org/ahpuh/surf/like/converter/LikeConverter.java new file mode 100644 index 00000000..4f50f5e3 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/like/converter/LikeConverter.java @@ -0,0 +1,18 @@ +package org.ahpuh.surf.like.converter; + +import org.ahpuh.surf.like.entity.Like; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.user.entity.User; +import org.springframework.stereotype.Component; + +@Component +public class LikeConverter { + + public Like toEntity(final User user, final Post post) { + return Like.builder() + .user(user) + .post(post) + .build(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/like/entity/Like.java b/src/main/java/org/ahpuh/surf/like/entity/Like.java new file mode 100644 index 00000000..2e7e90ee --- /dev/null +++ b/src/main/java/org/ahpuh/surf/like/entity/Like.java @@ -0,0 +1,43 @@ +package org.ahpuh.surf.like.entity; + +import lombok.*; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.user.entity.User; + +import javax.persistence.*; + +@Entity +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint( + columnNames = {"user_id", "post_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Like { + + @Id + @Column(name = "like_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long likeId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", referencedColumnName = "post_id") + private Post post; + + @Builder + public Like(final User user, final Post post) { + this.user = user; + this.post = post; + post.addLike(this); + } + +} diff --git a/src/main/java/org/ahpuh/surf/like/repository/LikeRepository.java b/src/main/java/org/ahpuh/surf/like/repository/LikeRepository.java new file mode 100644 index 00000000..aa1d86ee --- /dev/null +++ b/src/main/java/org/ahpuh/surf/like/repository/LikeRepository.java @@ -0,0 +1,7 @@ +package org.ahpuh.surf.like.repository; + +import org.ahpuh.surf.like.entity.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LikeRepository extends JpaRepository { +} diff --git a/src/main/java/org/ahpuh/surf/like/service/LikeService.java b/src/main/java/org/ahpuh/surf/like/service/LikeService.java new file mode 100644 index 00000000..7225a35b --- /dev/null +++ b/src/main/java/org/ahpuh/surf/like/service/LikeService.java @@ -0,0 +1,9 @@ +package org.ahpuh.surf.like.service; + +public interface LikeService { + + Long like(Long userId, Long postId); + + void unlike(Long postId, Long likeId); + +} diff --git a/src/main/java/org/ahpuh/surf/like/service/LikeServiceImpl.java b/src/main/java/org/ahpuh/surf/like/service/LikeServiceImpl.java new file mode 100644 index 00000000..edfa522d --- /dev/null +++ b/src/main/java/org/ahpuh/surf/like/service/LikeServiceImpl.java @@ -0,0 +1,48 @@ +package org.ahpuh.surf.like.service; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.common.exception.EntityExceptionHandler; +import org.ahpuh.surf.like.converter.LikeConverter; +import org.ahpuh.surf.like.entity.Like; +import org.ahpuh.surf.like.repository.LikeRepository; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Transactional +public class LikeServiceImpl implements LikeService { + + private final LikeRepository likeRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + + private final LikeConverter likeConverter; + + @Override + public Long like(final Long userId, final Long postId) { + final User userEntity = userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + final Post postEntity = postRepository.findById(postId) + .orElseThrow(() -> EntityExceptionHandler.PostNotFound(postId)); + return likeRepository.save(likeConverter.toEntity(userEntity, postEntity)) + .getLikeId(); + } + + @Override + public void unlike(final Long postId, final Long likeId) { + final Like like = likeRepository.findById(likeId) + .orElseThrow(() -> new IllegalArgumentException("좋아요한 기록이 없습니다." + likeId)); + if (!Objects.equals(like.getPost().getPostId(), postId)) { + throw new IllegalArgumentException("The post ID does not match. " + postId); + } + likeRepository.deleteById(likeId); + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/controller/PostController.java b/src/main/java/org/ahpuh/surf/post/controller/PostController.java new file mode 100644 index 00000000..29638209 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/controller/PostController.java @@ -0,0 +1,161 @@ +package org.ahpuh.surf.post.controller; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.category.dto.CategorySimpleDto; +import org.ahpuh.surf.common.response.CursorResult; +import org.ahpuh.surf.common.s3.S3Service; +import org.ahpuh.surf.common.s3.S3ServiceImpl.FileStatus; +import org.ahpuh.surf.jwt.JwtAuthentication; +import org.ahpuh.surf.post.dto.*; +import org.ahpuh.surf.post.service.PostService; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.io.IOException; +import java.net.URI; +import java.util.List; + +@RequiredArgsConstructor +@RequestMapping("/api/v1") +@RestController +public class PostController { + + private final PostService postService; + + private final S3Service s3Service; + + @PostMapping(value = "/posts", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createPost( + @AuthenticationPrincipal final JwtAuthentication authentication, + @Valid @RequestPart(value = "request") final PostRequestDto request, + @RequestPart(value = "file", required = false) final MultipartFile file + ) throws IOException { + final FileStatus fileStatus = s3Service.uploadPostFile(file); + final Long postId = postService.create(authentication.userId, request, fileStatus); + return ResponseEntity.created(URI.create("/api/v1/posts/" + postId)) + .body(postId); + } + + @PutMapping(value = "/posts/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updatePost( + @PathVariable final Long postId, + @Valid @RequestPart(value = "request") final PostRequestDto request, + @RequestPart(value = "file", required = false) final MultipartFile file + ) throws IOException { + final FileStatus fileStatus = s3Service.uploadPostFile(file); + final Long responsePostId = postService.update(postId, request, fileStatus); + return ResponseEntity.ok().body(responsePostId); + } + + @GetMapping("/posts/{postId}") + public ResponseEntity readPost( + @AuthenticationPrincipal final JwtAuthentication authentication, + @PathVariable final Long postId + ) { + final PostDto postDto = postService.readOne(authentication.userId, postId); + return ResponseEntity.ok().body(postDto); + } + + @DeleteMapping("/posts/{postId}") + public ResponseEntity deletePost( + @PathVariable final Long postId + ) { + postService.delete(postId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/posts/calendarGraph") + public ResponseEntity> getCounts( + @RequestParam final int year, + @RequestParam final Long userId + ) { + final List postCountDtos = postService.getCountsPerDayWithYear(year, userId); + return ResponseEntity.ok().body(postCountDtos); + } + + @GetMapping("/posts/score") + public ResponseEntity> getScores( + @RequestParam final Long userId + ) { + final List categorySimpleDtos = postService.getScoresWithCategoryByUserId(userId); + return ResponseEntity.ok().body(categorySimpleDtos); + } + + @PostMapping("/posts/{postId}/favorite") + public ResponseEntity makeFavorite( + @AuthenticationPrincipal final JwtAuthentication authentication, + @PathVariable final Long postId + ) { + final Long responsePostId = postService.clickFavorite(authentication.userId, postId); + return ResponseEntity.ok().body(responsePostId); + } + + @DeleteMapping("/posts/{postId}/favorite") + public ResponseEntity cancelFavorite( + @AuthenticationPrincipal final JwtAuthentication authentication, + @PathVariable final Long postId + ) { + postService.clickFavorite(authentication.userId, postId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/follow/posts") + public ResponseEntity> followingExplore( + @AuthenticationPrincipal final JwtAuthentication authentication, + @RequestParam final Long cursorId + ) { + final CursorResult followingPostDtos = postService.followingExplore(authentication.userId, cursorId, PageRequest.of(0, 10)); + return ResponseEntity.ok().body(followingPostDtos); + } + + @GetMapping("/posts/month") + public ResponseEntity> getPost( + @AuthenticationPrincipal final JwtAuthentication authentication, + @RequestParam final Integer year, + @RequestParam final Integer month + ) { + final Long userId = authentication.userId; + return ResponseEntity.ok().body(postService.getPost(userId, year, month)); + } + + @GetMapping("/posts/all") + public ResponseEntity> getAllPost( + @AuthenticationPrincipal final JwtAuthentication authentication, + @RequestParam final Long userId, + @RequestParam final Long cursorId + ) { + return ResponseEntity.ok().body(postService.getAllPost(authentication.userId, userId, cursorId, PageRequest.of(0, 10))); + } + + @GetMapping("/posts") + public ResponseEntity> getAllPostByCategory( + @AuthenticationPrincipal final JwtAuthentication authentication, + @RequestParam final Long userId, + @RequestParam final Long categoryId, + @RequestParam final Long cursorId + ) { + return ResponseEntity.ok().body(postService.getAllPostByCategory(authentication.userId, userId, categoryId, cursorId, PageRequest.of(0, 10))); + } + + @GetMapping("/recentscore") + public ResponseEntity getAllPostByCategory( + @RequestParam final Long categoryId + ) { + return ResponseEntity.ok().body(postService.getRecentScore(categoryId)); + } + + @GetMapping("/posts/recent") + public ResponseEntity> recentAllPosts( + @AuthenticationPrincipal final JwtAuthentication authentication, + @RequestParam final Long cursorId + ) { + final CursorResult recentAllPosts = postService.recentAllPosts(authentication.userId, cursorId, PageRequest.of(0, 10)); + return ResponseEntity.ok().body(recentAllPosts); + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/converter/PostConverter.java b/src/main/java/org/ahpuh/surf/post/converter/PostConverter.java new file mode 100644 index 00000000..543e754a --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/converter/PostConverter.java @@ -0,0 +1,149 @@ +package org.ahpuh.surf.post.converter; + +import org.ahpuh.surf.category.dto.CategorySimpleDto; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.common.exception.EntityExceptionHandler; +import org.ahpuh.surf.common.s3.S3ServiceImpl.FileStatus; +import org.ahpuh.surf.post.dto.*; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.user.entity.User; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class PostConverter { + + public Post toEntity(final User user, final Category category, final PostRequestDto request, final FileStatus fileStatus) { + final Post postEntity = Post.builder() + .user(user) + .category(category) + .selectedDate(LocalDate.parse(request.getSelectedDate())) // yyyy-mm-dd + .content(request.getContent()) + .score(request.getScore()) + .build(); + if (fileStatus != null) { + postEntity.editFile(fileStatus); + } + return postEntity; + } + + public PostDto toDto(final Post post, final Long myId) { + final PostDto dto = PostDto.builder() + .postId(post.getPostId()) + .userId(post.getUser().getUserId()) + .categoryId(post.getCategory().getCategoryId()) + .selectedDate(post.getSelectedDate().toString()) + .content(post.getContent()) + .score(post.getScore()) + .imageUrl(post.getImageUrl()) + .fileUrl(post.getFileUrl()) + .favorite(post.getFavorite()) + .createdAt(post.getCreatedAt().toString()) + .build(); + post.getLikes() + .stream() + .filter(like -> like.getUser().getUserId().equals(myId)) + .findFirst() + .ifPresent(likeEntity -> dto.setLiked(likeEntity.getLikeId())); + return dto; + } + + public PostResponseDto toPostResponseDto(final Post post, final Category category) { + return PostResponseDto.builder() + .categoryName(category.getName()) + .colorCode(category.getColorCode()) + .postId(post.getPostId()) + .content(post.getContent()) + .score(post.getScore()) + .imageUrl(post.getImageUrl()) + .fileUrl(post.getFileUrl()) + .selectedDate(post.getSelectedDate().toString()) + .build(); + } + + public AllPostResponseDto toAllPostResponseDto(final Post post, final Long myId) { + final AllPostResponseDto allPostResponseDto = AllPostResponseDto.builder() + .categoryName(post.getCategory().getName()) + .colorCode(post.getCategory().getColorCode()) + .postId(post.getPostId()) + .content(post.getContent()) + .score(post.getScore()) + .imageUrl(post.getImageUrl()) + .fileUrl(post.getFileUrl()) + .selectedDate(post.getSelectedDate().toString()) + .build(); + post.getLikes() + .stream() + .filter(like -> like.getUser().getUserId().equals(myId)) + .findFirst() + .ifPresent(likeEntity -> allPostResponseDto.setLiked(likeEntity.getLikeId())); + return allPostResponseDto; + } + + public List sortPostScoresByCategory( + final List posts, + final List categories) { + + final List categorySimpleDtos = categories.stream() + .map(category -> new CategorySimpleDto( + category.getCategoryId(), + category.getName(), + category.getColorCode(), + new ArrayList<>())) + .collect(Collectors.toList()); + + posts.forEach(postScoreCategoryDto -> { + final Category category = postScoreCategoryDto.getCategory(); + if (categories.contains(category)) { + categorySimpleDtos.stream() + .filter(categorySimpleDto -> categorySimpleDto.getCategoryId().equals(category.getCategoryId())) + .findFirst() + .map(categorySimpleDto -> categorySimpleDto.getPostScores() + .add(PostScoreDto.builder() + .x(postScoreCategoryDto.getSelectedDate()) + .y(postScoreCategoryDto.getScore()) + .build()) + ); + } else { + throw EntityExceptionHandler.CategoryNotFound(category.getCategoryId()); + } + + }); + + categorySimpleDtos.removeIf(categorySimpleDto -> categorySimpleDto.getPostScores().size() == 0); + + return categorySimpleDtos; + } + + public RecentPostDto toRecentAllPosts(final Post post, final User me) { + final RecentPostDto recentPostDto = RecentPostDto.builder() + .userId(post.getUser().getUserId()) + .userName(post.getUser().getUserName()) + .profilePhotoUrl(post.getUser().getProfilePhotoUrl()) + .categoryName(post.getCategory().getName()) + .colorCode(post.getCategory().getColorCode()) + .postId(post.getPostId()) + .content(post.getContent()) + .score(post.getScore()) + .selectedDate(post.getSelectedDate()) + .createdAt(post.getCreatedAt()) + .build(); + post.getLikes() + .stream() + .filter(like -> like.getUser().equals(me)) + .findFirst() + .ifPresent(like -> recentPostDto.setLiked(like.getLikeId())); + if (post.getUser() + .getFollowers() + .stream() + .anyMatch(follow -> follow.getUser().equals(me))) { + recentPostDto.checkFollowed(); + } + return recentPostDto; + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/AllPostResponseDto.java b/src/main/java/org/ahpuh/surf/post/dto/AllPostResponseDto.java new file mode 100644 index 00000000..d1024d35 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/AllPostResponseDto.java @@ -0,0 +1,38 @@ +package org.ahpuh.surf.post.dto; + +import lombok.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class AllPostResponseDto { + + private String categoryName; + + private String colorCode; + + private Long postId; + + private String content; + + private int score; + + private String imageUrl; + + private String fileUrl; + + private String selectedDate; + + @Builder.Default + private Long likeId = null; + + @Builder.Default + private Boolean isLiked = false; + + public void setLiked(final Long likeId) { + this.likeId = likeId; + this.isLiked = true; + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/ExploreDto.java b/src/main/java/org/ahpuh/surf/post/dto/ExploreDto.java new file mode 100644 index 00000000..e0869a0a --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/ExploreDto.java @@ -0,0 +1,79 @@ +package org.ahpuh.surf.post.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ExploreDto { + + private Long userId; + + private String userName; + + private String profilePhotoUrl; + + private String categoryName; + + private String colorCode; + + private Long postId; + + private String content; + + private Integer score; + + private String imageUrl; + + private String fileUrl; + + private LocalDate selectedDate; + + private LocalDateTime createdAt; + + @Builder.Default + private Long likeId = null; + + @Builder.Default + private Boolean isLiked = false; + + @QueryProjection + public ExploreDto(final Long userId, + final String userName, + final String profilePhotoUrl, + final String categoryName, + final String colorCode, + final Long postId, + final String content, + final Integer score, + final String imageUrl, + final String fileUrl, + final LocalDate selectedDate, + final LocalDateTime createdAt) { + this.userId = userId; + this.userName = userName; + this.profilePhotoUrl = profilePhotoUrl; + this.categoryName = categoryName; + this.colorCode = colorCode; + this.postId = postId; + this.content = content; + this.score = score; + this.imageUrl = imageUrl; + this.fileUrl = fileUrl; + this.selectedDate = selectedDate; + this.createdAt = createdAt; + this.likeId = null; + this.isLiked = false; + } + + public void setLiked(final Long likeId) { + this.likeId = likeId; + this.isLiked = true; + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/PostCountDto.java b/src/main/java/org/ahpuh/surf/post/dto/PostCountDto.java new file mode 100644 index 00000000..006a8abf --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/PostCountDto.java @@ -0,0 +1,24 @@ +package org.ahpuh.surf.post.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PostCountDto { + + private LocalDate date; + private Long count; + + @QueryProjection + public PostCountDto(final LocalDate date, final Long count) { + this.date = date; + this.count = count; + } +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/PostDto.java b/src/main/java/org/ahpuh/surf/post/dto/PostDto.java new file mode 100644 index 00000000..06f625ca --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/PostDto.java @@ -0,0 +1,40 @@ +package org.ahpuh.surf.post.dto; + +import lombok.*; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PostDto { + + private Long postId; + + private Long userId; + + private Long categoryId; + + private String selectedDate; + + private String content; + + private int score; + + private String imageUrl; + + private String fileUrl; + + private String createdAt; + + private Boolean favorite; + + private Long likeId; + + private Boolean isLiked; + + public void setLiked(final Long likeId) { + this.likeId = likeId; + this.isLiked = true; + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/PostRequestDto.java b/src/main/java/org/ahpuh/surf/post/dto/PostRequestDto.java new file mode 100644 index 00000000..dcf8f127 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/PostRequestDto.java @@ -0,0 +1,27 @@ +package org.ahpuh.surf.post.dto; + +import lombok.*; + +import javax.validation.constraints.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class PostRequestDto { + + @NotNull(message = "Invalid category ID.") + private Long categoryId; + + @NotBlank(message = "Selected Date type must be Date (yyyy-mm-dd).") + private String selectedDate; + + @NotBlank + @Size(max = 500, message = "Post contents length must within 500.") + private String content; + + @Min(0) + @Max(100) + private Integer score; + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/PostResponseDto.java b/src/main/java/org/ahpuh/surf/post/dto/PostResponseDto.java new file mode 100644 index 00000000..a2ed3458 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/PostResponseDto.java @@ -0,0 +1,27 @@ +package org.ahpuh.surf.post.dto; + +import lombok.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class PostResponseDto { + + private String categoryName; + + private String colorCode; + + private Long postId; + + private String content; + + private int score; + + private String imageUrl; + + private String fileUrl; + + private String selectedDate; + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/PostScoreCategoryDto.java b/src/main/java/org/ahpuh/surf/post/dto/PostScoreCategoryDto.java new file mode 100644 index 00000000..cb39a270 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/PostScoreCategoryDto.java @@ -0,0 +1,28 @@ +package org.ahpuh.surf.post.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.ahpuh.surf.category.entity.Category; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PostScoreCategoryDto { + + private Category category; + private LocalDate selectedDate; + private int score; + + @QueryProjection + public PostScoreCategoryDto(final Category category, final LocalDate selectedDate, final int score) { + this.category = category; + this.selectedDate = selectedDate; + this.score = score; + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/PostScoreDto.java b/src/main/java/org/ahpuh/surf/post/dto/PostScoreDto.java new file mode 100644 index 00000000..037f1d61 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/PostScoreDto.java @@ -0,0 +1,25 @@ +package org.ahpuh.surf.post.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PostScoreDto { + + private LocalDate x; // selectedDate + private int y; // score + + @QueryProjection + public PostScoreDto(final LocalDate selectedDate, final int score) { + this.x = selectedDate; + this.y = score; + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/dto/RecentPostDto.java b/src/main/java/org/ahpuh/surf/post/dto/RecentPostDto.java new file mode 100644 index 00000000..d9beb74e --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/dto/RecentPostDto.java @@ -0,0 +1,86 @@ +package org.ahpuh.surf.post.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RecentPostDto { + + private Long userId; + + private String userName; + + private String profilePhotoUrl; + + @Builder.Default + private boolean isFollowedUser = false; + + private String categoryName; + + private String colorCode; + + private Long postId; + + private String content; + + private Integer score; + + private String imageUrl; + + private String fileUrl; + + private LocalDate selectedDate; + + private LocalDateTime createdAt; + + @Builder.Default + private Long likeId = null; + + @Builder.Default + private boolean isLiked = false; + + @QueryProjection + public RecentPostDto(final Long userId, + final String userName, + final String profilePhotoUrl, + final String categoryName, + final String colorCode, + final Long postId, + final String content, + final Integer score, + final String imageUrl, + final String fileUrl, + final LocalDate selectedDate, + final LocalDateTime createdAt) { + this.userId = userId; + this.userName = userName; + this.profilePhotoUrl = profilePhotoUrl; + this.categoryName = categoryName; + this.colorCode = colorCode; + this.postId = postId; + this.content = content; + this.score = score; + this.imageUrl = imageUrl; + this.fileUrl = fileUrl; + this.selectedDate = selectedDate; + this.createdAt = createdAt; + this.likeId = null; + this.isLiked = false; + } + + public void setLiked(final Long likeId) { + this.likeId = likeId; + this.isLiked = true; + } + + public void checkFollowed() { + this.isFollowedUser = true; + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/entity/Post.java b/src/main/java/org/ahpuh/surf/post/entity/Post.java new file mode 100644 index 00000000..5aeceb01 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/entity/Post.java @@ -0,0 +1,107 @@ +package org.ahpuh.surf.post.entity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.common.entity.BaseEntity; +import org.ahpuh.surf.common.exception.EntityExceptionHandler; +import org.ahpuh.surf.common.s3.S3ServiceImpl.FileStatus; +import org.ahpuh.surf.like.entity.Like; +import org.ahpuh.surf.user.entity.User; +import org.hibernate.annotations.Where; + +import javax.persistence.*; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +@Where(clause = "is_deleted = false") +@Entity +@Table(name = "posts") +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "post_id", nullable = false) + private Long postId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", referencedColumnName = "category_id") + private Category category; + + @Column(name = "selected_date", nullable = false) + private LocalDate selectedDate; + + @Column(name = "content", nullable = false) + private String content; + + @Column(name = "score", nullable = false) + private int score; + + @Column(name = "file_url") + private String fileUrl; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "favorite") + @Builder.Default + private Boolean favorite = false; + + @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, orphanRemoval = true) + @Builder.Default + private List likes = new ArrayList<>(); + + @Builder + public Post(final User user, final Category category, final LocalDate selectedDate, final String content, final int score) { + this.user = user; + this.category = category; + this.selectedDate = selectedDate; + this.content = content; + this.score = score; + favorite = false; + user.addPost(this); + category.addPost(this); + } + + public void editPost(final Category category, final LocalDate selectedDate, final String content, final int score) { + this.category = category; + this.selectedDate = selectedDate; + this.content = content; + this.score = score; + } + + public Post editFile(final FileStatus fileStatus) { + if (fileStatus.fileType.equals("img")) { + this.imageUrl = fileStatus.fileUrl; + this.fileUrl = null; + } + if (fileStatus.fileType.equals("file")) { + this.fileUrl = fileStatus.fileUrl; + this.imageUrl = null; + } + return this; + } + + public void updateFavorite(final Long userId) { + if (!user.getUserId().equals(userId)) { + throw EntityExceptionHandler.UserNotMatching(user.getUserId(), userId); + } + favorite = !favorite; + } + + public void addLike(final Like like) { + likes.add(like); + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/repository/PostRepository.java b/src/main/java/org/ahpuh/surf/post/repository/PostRepository.java new file mode 100644 index 00000000..5c66e613 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/repository/PostRepository.java @@ -0,0 +1,33 @@ +package org.ahpuh.surf.post.repository; + +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.user.entity.User; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface PostRepository extends JpaRepository, PostRepositoryQuerydsl { + + List findAllByUserOrderBySelectedDateDesc(User user, Pageable page); + + List findAllByUserAndCategoryOrderBySelectedDateDesc(User user, Category category, Pageable page); + + List findAllByUserAndSelectedDateBetweenOrderBySelectedDate(User user, LocalDate start, LocalDate end); + + List findByUserAndSelectedDateIsLessThanEqualAndCreatedAtLessThanOrderBySelectedDateDesc(User user, LocalDate selectedDate, LocalDateTime createdAt, Pageable page); + + List findByUserAndCategoryAndSelectedDateLessThanEqualAndCreatedAtLessThanOrderBySelectedDateDesc(User user, Category category, LocalDate selectedDate, LocalDateTime createdAt, Pageable page); + + Post findTop1ByCategoryOrderBySelectedDateDesc(Category category); + + List findByCategory(Category category); + + List findTop10ByCreatedAtIsLessThanEqualOrderByCreatedAtDesc(LocalDateTime createdAt, Pageable page); + + List findTop10ByCreatedAtIsLessThanOrderByCreatedAtDesc(LocalDateTime createdAt, Pageable page); + +} diff --git a/src/main/java/org/ahpuh/surf/post/repository/PostRepositoryImpl.java b/src/main/java/org/ahpuh/surf/post/repository/PostRepositoryImpl.java new file mode 100644 index 00000000..990f8efb --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/repository/PostRepositoryImpl.java @@ -0,0 +1,109 @@ +package org.ahpuh.surf.post.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.post.dto.*; +import org.ahpuh.surf.user.entity.User; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.ahpuh.surf.follow.entity.QFollow.follow; +import static org.ahpuh.surf.post.entity.QPost.post; + +@RequiredArgsConstructor +public class PostRepositoryImpl implements PostRepositoryQuerydsl { + + private final JPAQueryFactory queryFactory; + + @Override + public List findFollowingPosts(final Long userId, final Pageable page) { + return queryFactory + .select(new QExploreDto( + post.user.userId.as("userId"), + post.user.userName.as("userName"), + post.user.profilePhotoUrl.as("profilePhotoUrl"), + post.category.name.as("categoryName"), + post.category.colorCode.as("colorCode"), + post.postId.as("postId"), + post.content.as("content"), + post.score.as("score"), + post.imageUrl.as("imageUrl"), + post.fileUrl.as("fileUrl"), + post.selectedDate, + post.createdAt.as("createdAt") + )) + .from(post) + .leftJoin(follow).on(follow.user.userId.eq(userId)) + .where( + follow.followedUser.userId.eq(post.user.userId), + post.isDeleted.eq(false) + ) + .groupBy(post.postId, follow.followId) + .orderBy(post.selectedDate.desc(), post.createdAt.desc()) + .limit(page.getPageSize()) + .fetch(); + } + + @Override + public List findNextFollowingPosts(final Long userId, final LocalDate selectedDate, final LocalDateTime createdAt, final Pageable page) { + return queryFactory + .select(new QExploreDto( + post.user.userId.as("userId"), + post.user.userName.as("userName"), + post.user.profilePhotoUrl.as("profilePhotoUrl"), + post.category.name.as("categoryName"), + post.category.colorCode.as("colorCode"), + post.postId.as("postId"), + post.content.as("content"), + post.score.as("score"), + post.imageUrl.as("imageUrl"), + post.fileUrl.as("fileUrl"), + post.selectedDate, + post.createdAt.as("createdAt") + )) + .from(post) + .leftJoin(follow).on(follow.user.userId.eq(userId)) + .where( + follow.followedUser.userId.eq(post.user.userId), + post.isDeleted.eq(false), + post.selectedDate.loe(selectedDate), + post.createdAt.before(createdAt) + ) + .groupBy(post.postId, follow.followId) + .orderBy(post.selectedDate.desc(), post.createdAt.desc()) + .limit(page.getPageSize()) + .fetch(); + } + + @Override + public List findAllDateAndCountBetween(final int year, final User user) { + return queryFactory + .select(new QPostCountDto( + post.selectedDate.as("date"), + post.selectedDate.count().as("count"))) + .from(post) + .where(post.selectedDate.between(LocalDate.of(year, 1, 1), LocalDate.of(year, 12, 31)), + post.user.eq(user), post.isDeleted.eq(false)) + .groupBy(post.selectedDate) + .orderBy(post.selectedDate.asc()) + .fetch(); + } + + @Override + public List findAllScoreWithCategoryByUser(final User user) { + return queryFactory + .select(new QPostScoreCategoryDto( + post.category.as("category"), + post.selectedDate.as("selectedDate"), + post.score.as("score") + )) + .from(post) + .where(post.user.eq(user), post.isDeleted.eq(false)) + .orderBy(post.category.categoryId.asc(), post.selectedDate.asc()) + .fetch(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/post/repository/PostRepositoryQuerydsl.java b/src/main/java/org/ahpuh/surf/post/repository/PostRepositoryQuerydsl.java new file mode 100644 index 00000000..945a2a7f --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/repository/PostRepositoryQuerydsl.java @@ -0,0 +1,23 @@ +package org.ahpuh.surf.post.repository; + +import org.ahpuh.surf.post.dto.ExploreDto; +import org.ahpuh.surf.post.dto.PostCountDto; +import org.ahpuh.surf.post.dto.PostScoreCategoryDto; +import org.ahpuh.surf.user.entity.User; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface PostRepositoryQuerydsl { + + List findFollowingPosts(Long userId, Pageable page); + + List findNextFollowingPosts(Long userId, LocalDate selectedDate, LocalDateTime createdAt, Pageable page); + + List findAllDateAndCountBetween(int year, User user); + + List findAllScoreWithCategoryByUser(User user); + +} diff --git a/src/main/java/org/ahpuh/surf/post/service/PostService.java b/src/main/java/org/ahpuh/surf/post/service/PostService.java new file mode 100644 index 00000000..6ac11914 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/service/PostService.java @@ -0,0 +1,40 @@ +package org.ahpuh.surf.post.service; + +import org.ahpuh.surf.category.dto.CategorySimpleDto; +import org.ahpuh.surf.common.response.CursorResult; +import org.ahpuh.surf.common.s3.S3ServiceImpl.FileStatus; +import org.ahpuh.surf.post.dto.*; +import org.springframework.data.domain.Pageable; + +import java.util.List; + + +public interface PostService { + + Long create(Long userId, PostRequestDto request, FileStatus fileStatus); + + Long update(Long postId, PostRequestDto request, FileStatus fileStatus); + + PostDto readOne(Long myId, Long postId); + + void delete(Long postID); + + Long clickFavorite(Long userId, Long postId); + + List getCountsPerDayWithYear(int year, Long userId); + + List getScoresWithCategoryByUserId(Long userId); + + CursorResult followingExplore(Long userId, Long cursorId, Pageable page); + + List getPost(Long userId, Integer year, Integer month); + + CursorResult getAllPost(Long myId, Long userId, Long cursorId, Pageable page); + + CursorResult getAllPostByCategory(Long myId, Long userId, Long categoryId, Long cursorId, Pageable page); + + int getRecentScore(Long categoryId); + + CursorResult recentAllPosts(Long myId, Long cursorId, Pageable page); + +} diff --git a/src/main/java/org/ahpuh/surf/post/service/PostServiceImpl.java b/src/main/java/org/ahpuh/surf/post/service/PostServiceImpl.java new file mode 100644 index 00000000..9f249fa9 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/post/service/PostServiceImpl.java @@ -0,0 +1,243 @@ +package org.ahpuh.surf.post.service; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.category.dto.CategorySimpleDto; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.common.exception.EntityExceptionHandler; +import org.ahpuh.surf.common.response.CursorResult; +import org.ahpuh.surf.common.s3.S3ServiceImpl.FileStatus; +import org.ahpuh.surf.post.converter.PostConverter; +import org.ahpuh.surf.post.dto.*; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class PostServiceImpl implements PostService { + + private final PostRepository postRepository; + private final CategoryRepository categoryRepository; + private final UserRepository userRepository; + private final PostConverter postConverter; + + @Transactional + public Long create(final Long userId, final PostRequestDto request, final FileStatus fileStatus) { + final User user = getUserById(userId); + final Category category = getCategoryById(request.getCategoryId()); + + final Post post = postConverter.toEntity(user, category, request, fileStatus); + final Post saved = postRepository.save(post); + + return saved.getPostId(); + } + + @Transactional + public Long update(final Long postId, final PostRequestDto request, final FileStatus fileStatus) { + final Category category = getCategoryById(request.getCategoryId()); + final Post post = getPostById(postId); + post.editPost(category, LocalDate.parse(request.getSelectedDate()), request.getContent(), request.getScore()); + if (fileStatus != null) { + post.editFile(fileStatus); + } + + return postId; + } + + public PostDto readOne(final Long myId, final Long postId) { + return postConverter.toDto(getPostById(postId), myId); + } + + @Transactional + public void delete(final Long postId) { + final Post post = getPostById(postId); + post.delete(); + } + + @Transactional + public Long clickFavorite(final Long userId, final Long postId) { + final Post post = getPostById(postId); + post.updateFavorite(userId); + return post.getPostId(); + } + + @Override + public CursorResult followingExplore(final Long myId, final Long cursorId, final Pageable page) { + final User me = userRepository.findById(myId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(myId)); + if (me.getFollowing().isEmpty()) { + return new CursorResult<>(List.of(), false); + } + + final Post findPost = postRepository.findById(cursorId).orElse(null); + + final List exploreDtos = findPost == null + ? postRepository.findFollowingPosts(myId, page) + : postRepository.findNextFollowingPosts(myId, findPost.getSelectedDate(), findPost.getCreatedAt(), page); + + for (final ExploreDto dto : exploreDtos) { + me.getLikes() + .stream() + .filter(like -> like.getPost().getPostId().equals(dto.getPostId())) + .findFirst() + .ifPresent(like -> dto.setLiked(like.getLikeId())); + } + + if (exploreDtos.isEmpty()) { + return new CursorResult<>(List.of(), false); + } else { + final ExploreDto lastExploreDto = exploreDtos.get(exploreDtos.size() - 1); + final boolean hasNext = !postRepository.findNextFollowingPosts(myId, lastExploreDto.getSelectedDate(), lastExploreDto.getCreatedAt(), page).isEmpty(); + return new CursorResult<>(exploreDtos, hasNext); + } + + } + + public List getCountsPerDayWithYear(final int year, final Long userId) { + final User user = getUserById(userId); + return postRepository.findAllDateAndCountBetween(year, user); + } + + public List getScoresWithCategoryByUserId(final Long userId) { + final User user = getUserById(userId); + final List posts = postRepository.findAllScoreWithCategoryByUser(user); + final List categories = categoryRepository.findAll(); + return postConverter.sortPostScoresByCategory(posts, categories); + } + + private User getUserById(final Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + } + + @Override + public List getPost(final Long userId, final Integer year, final Integer month) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + final LocalDate start = LocalDate.of(year, month, 1); + final LocalDate end = start.withDayOfMonth(start.lengthOfMonth()); + final List postList = postRepository.findAllByUserAndSelectedDateBetweenOrderBySelectedDate(user, start, end); + + return postList.stream() + .map((Post post) -> postConverter.toPostResponseDto(post, post.getCategory())) + .toList(); + } + + @Override + public CursorResult getAllPost(final Long myId, final Long userId, final Long cursorId, final Pageable page) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + + final Post findPost = postRepository.findById(cursorId).orElse(null); + + final List postList = findPost == null + ? postRepository.findAllByUserOrderBySelectedDateDesc(user, page) + : postRepository.findByUserAndSelectedDateIsLessThanEqualAndCreatedAtLessThanOrderBySelectedDateDesc(user, findPost.getSelectedDate(), findPost.getCreatedAt(), page); + + if (postList.isEmpty()) { + return new CursorResult<>(List.of(), false); + } + + final List posts = postList.stream() + .map(post -> postConverter.toAllPostResponseDto(post, myId)) + .toList(); + + final Post lastPost = postList.get(postList.size() - 1); + final boolean hasNext = !postRepository.findByUserAndSelectedDateIsLessThanEqualAndCreatedAtLessThanOrderBySelectedDateDesc( + user, + lastPost.getSelectedDate(), + lastPost.getCreatedAt(), + page) + .isEmpty(); + + return new CursorResult<>(posts, hasNext); + } + + @Override + public CursorResult getAllPostByCategory(final Long myId, final Long userId, final Long categoryId, final Long cursorId, final Pageable page) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(userId)); + final Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> EntityExceptionHandler.CategoryNotFound(categoryId)); + + final Post findPost = postRepository.findById(cursorId).orElse(null); + + final List postList = findPost == null + ? postRepository.findAllByUserAndCategoryOrderBySelectedDateDesc(user, category, page) + : postRepository.findByUserAndCategoryAndSelectedDateLessThanEqualAndCreatedAtLessThanOrderBySelectedDateDesc(user, category, findPost.getSelectedDate(), findPost.getCreatedAt(), page); + + if (postList.isEmpty()) { + return new CursorResult<>(List.of(), false); + } + + final List posts = postList.stream() + .map(post -> postConverter.toAllPostResponseDto(post, myId)) + .toList(); + + final Post lastPost = postList.get(postList.size() - 1); + final boolean hasNext = !postRepository.findByUserAndCategoryAndSelectedDateLessThanEqualAndCreatedAtLessThanOrderBySelectedDateDesc( + user, + category, + lastPost.getSelectedDate(), + lastPost.getCreatedAt(), + page) + .isEmpty(); + + return new CursorResult<>(posts, hasNext); + } + + public int getRecentScore(final Long categoryId) { + final Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> EntityExceptionHandler.CategoryNotFound(categoryId)); + final Post post = postRepository.findTop1ByCategoryOrderBySelectedDateDesc(category); + + return post.getScore(); + } + + private Category getCategoryById(final Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> EntityExceptionHandler.CategoryNotFound(categoryId)); + } + + private Post getPostById(final Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> EntityExceptionHandler.PostNotFound(postId)); + } + + public CursorResult recentAllPosts(final Long myId, final Long cursorId, final Pageable page) { + final User me = userRepository.findById(myId) + .orElseThrow(() -> EntityExceptionHandler.UserNotFound(myId)); + final Post findPost = postRepository.findById(cursorId).orElse(null); + + final List postList = findPost == null + ? postRepository.findTop10ByCreatedAtIsLessThanEqualOrderByCreatedAtDesc(LocalDateTime.now(), page) + : postRepository.findTop10ByCreatedAtIsLessThanOrderByCreatedAtDesc(findPost.getCreatedAt(), page); + + if (postList.isEmpty()) { + return new CursorResult<>(List.of(), false); + } + + final List posts = postList.stream() + .map(postEntity -> postConverter.toRecentAllPosts(postEntity, me)) + .toList(); + + final Post lastPost = postList.get(postList.size() - 1); + final boolean hasNext = !postRepository.findTop10ByCreatedAtIsLessThanOrderByCreatedAtDesc( + lastPost.getCreatedAt(), + page) + .isEmpty(); + + return new CursorResult<>(posts, hasNext); + } + +} diff --git a/src/main/java/org/ahpuh/surf/user/controller/UserController.java b/src/main/java/org/ahpuh/surf/user/controller/UserController.java new file mode 100644 index 00000000..cac4f797 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/controller/UserController.java @@ -0,0 +1,71 @@ +package org.ahpuh.surf.user.controller; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.common.s3.S3Service; +import org.ahpuh.surf.jwt.JwtAuthentication; +import org.ahpuh.surf.user.dto.*; +import org.ahpuh.surf.user.service.UserService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.io.IOException; +import java.net.URI; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + private final S3Service s3Service; + + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody final UserLoginRequestDto request + ) { + final UserLoginResponseDto loginResponse = userService.authenticate(request.getEmail(), request.getPassword()); + return ResponseEntity.ok().body(loginResponse); + } + + @PostMapping + public ResponseEntity join( + @Valid @RequestBody final UserJoinRequestDto request + ) { + final long userId = userService.join(request); + return ResponseEntity.created(URI.create("/api/v1/users/" + userId)) + .body(userId); + } + + @GetMapping("/{userId}") + public ResponseEntity findUserInfo( + @PathVariable final Long userId + ) { + final UserDto response = userService.findById(userId); + return ResponseEntity.ok().body(response); + } + + @PutMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateUser( + @AuthenticationPrincipal final JwtAuthentication authentication, + @Valid @RequestPart(value = "request") final UserUpdateRequestDto request, + @RequestPart(value = "file", required = false) final MultipartFile profilePhoto + ) throws IOException { + final String profilePhotoUrl = s3Service.uploadUserImg(profilePhoto); + userService.update(authentication.userId, request, profilePhotoUrl); + return ResponseEntity.ok().body(authentication.userId); + } + + @DeleteMapping + public ResponseEntity deleteUser( + @AuthenticationPrincipal final JwtAuthentication authentication + ) { + userService.delete(authentication.userId); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/user/converter/UserConverter.java b/src/main/java/org/ahpuh/surf/user/converter/UserConverter.java new file mode 100644 index 00000000..3130c501 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/converter/UserConverter.java @@ -0,0 +1,38 @@ +package org.ahpuh.surf.user.converter; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.user.dto.UserDto; +import org.ahpuh.surf.user.dto.UserJoinRequestDto; +import org.ahpuh.surf.user.entity.User; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserConverter { + + private final PasswordEncoder bCryptEncoder; + + public User toEntity(final UserJoinRequestDto dto) { + return User.builder() + .email(dto.getEmail()) + .password(bCryptEncoder.encode(dto.getPassword())) + .userName(dto.getUserName()) + .build(); + } + + public UserDto toUserDto(final User userEntity, final long followingCount, final long followerCount) { + return UserDto.builder() + .userId(userEntity.getUserId()) + .email(userEntity.getEmail()) + .userName(userEntity.getUserName()) + .profilePhotoUrl(userEntity.getProfilePhotoUrl()) + .aboutMe(userEntity.getAboutMe()) + .url(userEntity.getUrl()) + .followingCount(followingCount) + .followerCount(followerCount) + .accountPublic(userEntity.getAccountPublic()) + .build(); + } + +} diff --git a/src/main/java/org/ahpuh/surf/user/dto/UserDto.java b/src/main/java/org/ahpuh/surf/user/dto/UserDto.java new file mode 100644 index 00000000..d0fdaf7c --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/dto/UserDto.java @@ -0,0 +1,29 @@ +package org.ahpuh.surf.user.dto; + +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class UserDto { + + private Long userId; + + private String email; + + private String userName; + + private String profilePhotoUrl; + + private String aboutMe; + + private String url; + + private long followerCount; + + private long followingCount; + + private Boolean accountPublic; + +} diff --git a/src/main/java/org/ahpuh/surf/user/dto/UserJoinRequestDto.java b/src/main/java/org/ahpuh/surf/user/dto/UserJoinRequestDto.java new file mode 100644 index 00000000..109e6c38 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/dto/UserJoinRequestDto.java @@ -0,0 +1,25 @@ +package org.ahpuh.surf.user.dto; + +import lombok.*; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class UserJoinRequestDto { + + @Email(message = "Invalid email.") + private String email; + + @NotBlank(message = "Password must be provided.") + private String password; + + @NotBlank(message = "UserName must be provided.") + @Size(max = 20, message = "UserName length must within 20.") + private String userName; + +} diff --git a/src/main/java/org/ahpuh/surf/user/dto/UserJoinResponseDto.java b/src/main/java/org/ahpuh/surf/user/dto/UserJoinResponseDto.java new file mode 100644 index 00000000..e0d74dcb --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/dto/UserJoinResponseDto.java @@ -0,0 +1,17 @@ +package org.ahpuh.surf.user.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class UserJoinResponseDto { + + private String email; + + private String password; + +} diff --git a/src/main/java/org/ahpuh/surf/user/dto/UserLoginRequestDto.java b/src/main/java/org/ahpuh/surf/user/dto/UserLoginRequestDto.java new file mode 100644 index 00000000..2c9f0b82 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/dto/UserLoginRequestDto.java @@ -0,0 +1,20 @@ +package org.ahpuh.surf.user.dto; + +import lombok.*; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class UserLoginRequestDto { + + @Email(message = "Invalid email.") + private String email; + + @NotBlank(message = "password must be provided.") + private String password; + +} diff --git a/src/main/java/org/ahpuh/surf/user/dto/UserLoginResponseDto.java b/src/main/java/org/ahpuh/surf/user/dto/UserLoginResponseDto.java new file mode 100644 index 00000000..5ac4af84 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/dto/UserLoginResponseDto.java @@ -0,0 +1,15 @@ +package org.ahpuh.surf.user.dto; + +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class UserLoginResponseDto { + + private String token; + + private Long userId; + +} diff --git a/src/main/java/org/ahpuh/surf/user/dto/UserUpdateRequestDto.java b/src/main/java/org/ahpuh/surf/user/dto/UserUpdateRequestDto.java new file mode 100644 index 00000000..f8496c62 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/dto/UserUpdateRequestDto.java @@ -0,0 +1,28 @@ +package org.ahpuh.surf.user.dto; + +import lombok.*; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class UserUpdateRequestDto { + + @NotBlank(message = "UserName must be provided.") + @Size(max = 20, message = "UserName length must within 20.") + private String userName; + + private String password; + + private String url; + + private String aboutMe; + + @NotNull + private Boolean accountPublic; + +} \ No newline at end of file diff --git a/src/main/java/org/ahpuh/surf/user/entity/Permission.java b/src/main/java/org/ahpuh/surf/user/entity/Permission.java new file mode 100644 index 00000000..b6da9dba --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/entity/Permission.java @@ -0,0 +1,19 @@ +package org.ahpuh.surf.user.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; + +@Getter +@JsonFormat(shape = JsonFormat.Shape.STRING) +public enum Permission { + ROLE_USER("USER"), + ROLE_ADMIN("ADMIN"); + + @JsonValue + private final String role; + + Permission(final String role) { + this.role = role; + } +} diff --git a/src/main/java/org/ahpuh/surf/user/entity/User.java b/src/main/java/org/ahpuh/surf/user/entity/User.java new file mode 100644 index 00000000..d36853f0 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/entity/User.java @@ -0,0 +1,124 @@ +package org.ahpuh.surf.user.entity; + +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.common.entity.BaseEntity; +import org.ahpuh.surf.follow.entity.Follow; +import org.ahpuh.surf.like.entity.Like; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.user.dto.UserUpdateRequestDto; +import org.hibernate.annotations.Where; +import org.springframework.security.crypto.password.PasswordEncoder; + +import javax.persistence.*; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +@Where(clause = "is_deleted = false") +public class User extends BaseEntity { + + @Id + @Column(name = "user_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + + @Column(name = "user_name", nullable = false) + private String userName; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "profile_photo_url") + private String profilePhotoUrl; + + @Column(name = "url") + private String url; + + @Column(name = "about_me") + private String aboutMe; + + @Column(name = "account_public", columnDefinition = "boolean default true") + @Builder.Default + private Boolean accountPublic = true; + + @Column(name = "permission") + @Enumerated(value = EnumType.STRING) + @Builder.Default + private Permission permission = Permission.ROLE_USER; + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true) + @Builder.Default + private List categories = new ArrayList<>(); + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true) + @Builder.Default + private List posts = new ArrayList<>(); + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true) + @Builder.Default + private List following = new ArrayList<>(); // 내가 팔로잉한 + + @OneToMany(mappedBy = "followedUser", fetch = FetchType.LAZY, orphanRemoval = true) + @Builder.Default + private List followers = new ArrayList<>(); // 나를 팔로우한 + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true) + @Builder.Default + private List likes = new ArrayList<>(); + + @Builder + public User(final String email, final String password, final String userName) { + this.email = email; + this.password = password; + this.userName = userName; + } + + public void checkPassword(final PasswordEncoder passwordEncoder, final String credentials) { + if (!passwordEncoder.matches(credentials, password)) + throw new IllegalArgumentException("Bad credential"); + } + + public void setPermission(final Permission permission) { + this.permission = permission; + } + + public void update(final PasswordEncoder passwordEncoder, final UserUpdateRequestDto request, final String profilePhotoUrl) { + this.userName = request.getUserName(); + this.url = request.getUrl(); + this.aboutMe = request.getAboutMe(); + this.accountPublic = request.getAccountPublic(); + if (request.getPassword() != null) { + this.password = passwordEncoder.encode(request.getPassword()); + } + if (profilePhotoUrl != null) { + this.profilePhotoUrl = profilePhotoUrl; + } + } + + public void addCategory(final Category category) { + categories.add(category); + } + + public void addPost(final Post post) { + posts.add(post); + } + + public void addFollowing(final Follow followingUser) { + following.add(followingUser); + } + + public void addFollowers(final Follow follower) { + followers.add(follower); + } + +} diff --git a/src/main/java/org/ahpuh/surf/user/repository/UserRepository.java b/src/main/java/org/ahpuh/surf/user/repository/UserRepository.java new file mode 100644 index 00000000..6aa2a863 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +package org.ahpuh.surf.user.repository; + +import org.ahpuh.surf.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + Boolean existsByEmail(String email); + +} diff --git a/src/main/java/org/ahpuh/surf/user/service/UserService.java b/src/main/java/org/ahpuh/surf/user/service/UserService.java new file mode 100644 index 00000000..a47344d8 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/service/UserService.java @@ -0,0 +1,23 @@ +package org.ahpuh.surf.user.service; + +import org.ahpuh.surf.user.dto.UserDto; +import org.ahpuh.surf.user.dto.UserJoinRequestDto; +import org.ahpuh.surf.user.dto.UserLoginResponseDto; +import org.ahpuh.surf.user.dto.UserUpdateRequestDto; +import org.ahpuh.surf.user.entity.User; + +public interface UserService { + + UserLoginResponseDto authenticate(final String email, final String password); + + User login(final String email, final String password); + + Long join(final UserJoinRequestDto joinRequest); + + UserDto findById(Long userId); + + Long update(Long userId, UserUpdateRequestDto updateDto, String profilePhotoUrl); + + void delete(Long userId); + +} diff --git a/src/main/java/org/ahpuh/surf/user/service/UserServiceImpl.java b/src/main/java/org/ahpuh/surf/user/service/UserServiceImpl.java new file mode 100644 index 00000000..9e331aa6 --- /dev/null +++ b/src/main/java/org/ahpuh/surf/user/service/UserServiceImpl.java @@ -0,0 +1,89 @@ +package org.ahpuh.surf.user.service; + +import lombok.RequiredArgsConstructor; +import org.ahpuh.surf.common.entity.BaseEntity; +import org.ahpuh.surf.follow.repository.FollowRepository; +import org.ahpuh.surf.jwt.JwtAuthentication; +import org.ahpuh.surf.jwt.JwtAuthenticationToken; +import org.ahpuh.surf.user.converter.UserConverter; +import org.ahpuh.surf.user.dto.UserDto; +import org.ahpuh.surf.user.dto.UserJoinRequestDto; +import org.ahpuh.surf.user.dto.UserLoginResponseDto; +import org.ahpuh.surf.user.dto.UserUpdateRequestDto; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static org.ahpuh.surf.common.exception.EntityExceptionHandler.UserNotFound; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserServiceImpl implements UserService { + + private final AuthenticationManager authenticationManager; + private final PasswordEncoder passwordEncoder; + + private final UserRepository userRepository; + private final FollowRepository followRepository; + private final UserConverter userConverter; + + public UserLoginResponseDto authenticate(final String email, final String password) { + final JwtAuthenticationToken authToken = new JwtAuthenticationToken(email, password); + final Authentication resultToken = authenticationManager.authenticate(authToken); + final JwtAuthentication authentication = (JwtAuthentication) resultToken.getPrincipal(); + final User user = (User) resultToken.getDetails(); + return new UserLoginResponseDto(authentication.token, user.getUserId()); + } + + public User login(final String email, final String password) { + final User user = userRepository.findByEmail(email) + .orElseThrow(() -> UserNotFound(email)); + user.checkPassword(passwordEncoder, password); + return user; + } + + @Transactional + public Long join(final UserJoinRequestDto joinRequest) { + if (userRepository.existsByEmail(joinRequest.getEmail())) { + throw new IllegalArgumentException(String.format("Email is duplicated. email=%s", joinRequest.getEmail())); + } + final User newUser = userRepository.save(userConverter.toEntity(joinRequest)); + return newUser.getUserId(); + } + + @Override + public UserDto findById(final Long userId) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> UserNotFound(userId)); + final long followingCount = followRepository.countByUser(user); + final long followerCount = followRepository.countByFollowedUser(user); + return userConverter.toUserDto(user, followingCount, followerCount); + } + + @Override + @Transactional + public Long update(final Long userId, final UserUpdateRequestDto updateDto, final String profilePhotoUrl) { + final User userEntity = userRepository.findById(userId) + .orElseThrow(() -> UserNotFound(userId)); + userEntity.update(passwordEncoder, updateDto, profilePhotoUrl); + return userEntity.getUserId(); + } + + @Override + @Transactional + public void delete(final Long userId) { + final User userEntity = userRepository.findById(userId) + .orElseThrow(() -> UserNotFound(userId)); + userEntity.delete(); + userEntity.getCategories() + .forEach(BaseEntity::delete); + userEntity.getPosts() + .forEach(BaseEntity::delete); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b137891..00000000 --- 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 00000000..c5d0f8b2 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,39 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PW} + jpa: + open-in-view: true + show-sql: true + hibernate: + ddl-auto: none + use-new-id-generator-mappings: false + properties: + hibernate.dialect: org.hibernate.dialect.MySQL8Dialect + profiles: + include: + - slack-logging +server: + port: 8080 +jwt: + header: token + issuer: ahpuh + client-secret: ${JWT_CLIENT_SECRET} + expiry-seconds: 2592000 +cloud: + aws: + credentials: + accessKey: ${AWS_ACCESS_KEY_ID} + secretKey: ${AWS_SECRET_ACCESS_KEY} + s3: + bucket: ${AWS_S3_BUCKET_NAME} + region: + static: ap-northeast-2 + stack: + auto: false +logging: + slack: + webhook-uri: ${SLACK_WEBHOOK_URI} + config: classpath:logback-slack.xml diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 00000000..c5b722f2 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,12 @@ + + $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\ $$$$$$\ $$\ $$\ $$\ $$\ +$$ __$$\ $$ | $$ | $$ __$$\ $$ __$$\ $$ __$$\ $$ |\__| $$ | \__| +$$ / $$ |$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$$\ $$ / \__|$$\ $$\ $$$$$$\ $$ / \__| $$ / $$ | $$$$$$\ $$$$$$\ $$ |$$\ $$$$$$$\ $$$$$$\ $$$$$$\ $$\ $$$$$$\ $$$$$$$\ +$$$$$$$$ |$$ __$$\ $$ __$$\ $$ | $$ |$$ __$$\ $$$$$$\ \$$$$$$\ $$ | $$ |$$ __$$\ $$$$\ $$$$$$$$ |$$ __$$\ $$ __$$\ $$ |$$ |$$ _____|\____$$\\_$$ _| $$ |$$ __$$\ $$ __$$\ +$$ __$$ |$$ | $$ |$$ / $$ |$$ | $$ |$$ | $$ | \______| \____$$\ $$ | $$ |$$ | \__|$$ _| $$ __$$ |$$ / $$ |$$ / $$ |$$ |$$ |$$ / $$$$$$$ | $$ | $$ |$$ / $$ |$$ | $$ | +$$ | $$ |$$ | $$ |$$ | $$ |$$ | $$ |$$ | $$ | $$\ $$ |$$ | $$ |$$ | $$ | $$ | $$ |$$ | $$ |$$ | $$ |$$ |$$ |$$ | $$ __$$ | $$ |$$\ $$ |$$ | $$ |$$ | $$ | +$$ | $$ |$$ | $$ |$$$$$$$ |\$$$$$$ |$$ | $$ | \$$$$$$ |\$$$$$$ |$$ | $$ | $$ | $$ |$$$$$$$ |$$$$$$$ |$$ |$$ |\$$$$$$$\\$$$$$$$ | \$$$$ |$$ |\$$$$$$ |$$ | $$ | +\__| \__|\__| \__|$$ ____/ \______/ \__| \__| \______/ \______/ \__| \__| \__| \__|$$ ____/ $$ ____/ \__|\__| \_______|\_______| \____/ \__| \______/ \__| \__| + $$ | $$ | $$ | + $$ | $$ | $$ | + \__| \__| \__| diff --git a/src/main/resources/logback-slack.xml b/src/main/resources/logback-slack.xml new file mode 100644 index 00000000..83be4356 --- /dev/null +++ b/src/main/resources/logback-slack.xml @@ -0,0 +1,25 @@ + + + + + ${SLACK_WEBHOOK_URI} + + %-4relative [%thread] %-5level %class - %msg%n + + posting bot + :stuck_out_tongue_winking_eye: + true + + + + + ERROR + + + + + + + + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 00000000..85bc0639 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,30 @@ + + + + + + + + + + ./logs/surf.log + + _%d{yyyyMMdd HH:mm:ss.SSS} [%thread] %-5level [%logger{0}:%line] - %msg %n + + + surf.log.%d{yyyy-MM-dd}.%i.gz + + 100MB + + 180 + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql new file mode 100644 index 00000000..b2533640 --- /dev/null +++ b/src/main/resources/sql/schema.sql @@ -0,0 +1,72 @@ +DROP TABLE IF EXISTS likes CASCADE; +DROP TABLE IF EXISTS follow CASCADE; +DROP TABLE IF EXISTS posts CASCADE; +DROP TABLE IF EXISTS categories CASCADE; +DROP TABLE IF EXISTS users CASCADE; + +CREATE TABLE users +( + user_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_name VARCHAR(20) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(60) NOT NULL, + profile_photo_url TEXT, + url VARCHAR(255), + about_me VARCHAR(255), + account_public BOOLEAN DEFAULT true, + permission VARCHAR(20) DEFAULT "ROLE_USER", + created_at TIMESTAMP DEFAULT current_timestamp, + updated_at TIMESTAMP DEFAULT current_timestamp, + is_deleted BOOLEAN DEFAULT false +); + +CREATE TABLE categories +( + category_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(30) NOT NULL, + is_public BOOLEAN DEFAULT true, + color_code VARCHAR(10), + created_at TIMESTAMP DEFAULT current_timestamp, + updated_at TIMESTAMP DEFAULT current_timestamp, + is_deleted BOOLEAN DEFAULT false, + CONSTRAINT fk_user_id_for_category FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE RESTRICT ON UPDATE RESTRICT +); + +CREATE TABLE posts +( + post_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + selected_date DATE NOT NULL, + content VARCHAR(500) NOT NULL, + score INT NOT NULL, + image_url TEXT, + file_url TEXT, + favorite BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT current_timestamp, + updated_at TIMESTAMP DEFAULT current_timestamp, + is_deleted BOOLEAN DEFAULT false, + CONSTRAINT fk_user_id_for_post FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT fk_category_id_for_post FOREIGN KEY (category_id) REFERENCES categories (category_id) ON DELETE RESTRICT ON UPDATE RESTRICT +); + +CREATE TABLE follow +( + follow_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + following_id BIGINT NOT NULL, + CONSTRAINT fk_user_id_for_follow FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT fk_following_id_for_follow FOREIGN KEY (following_id) REFERENCES users (user_id) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT uk_user_id_and_following_id_for_follow UNIQUE (user_id, following_id) +); + +CREATE TABLE likes +( + like_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + post_id BIGINT NOT NULL, + CONSTRAINT fk_user_id_for_like FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT fk_post_id_for_like FOREIGN KEY (post_id) REFERENCES posts (post_id) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT uk_user_id_and_post_id_for_like UNIQUE (user_id, post_id) +); diff --git a/src/test/java/org/ahpuh/backend/BackendApplicationTests.java b/src/test/java/org/ahpuh/backend/BackendApplicationTests.java deleted file mode 100644 index 68ec2c81..00000000 --- a/src/test/java/org/ahpuh/backend/BackendApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.ahpuh.backend; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class BackendApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/org/ahpuh/surf/category/controller/CategoryControllerTest.java b/src/test/java/org/ahpuh/surf/category/controller/CategoryControllerTest.java new file mode 100644 index 00000000..311f0354 --- /dev/null +++ b/src/test/java/org/ahpuh/surf/category/controller/CategoryControllerTest.java @@ -0,0 +1,141 @@ +package org.ahpuh.surf.category.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.ahpuh.surf.category.dto.CategoryCreateRequestDto; +import org.ahpuh.surf.category.dto.CategoryUpdateRequestDto; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.user.dto.UserLoginResponseDto; +import org.ahpuh.surf.user.entity.Permission; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.ahpuh.surf.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@SpringBootTest +@Transactional +class CategoryControllerTest { + + User user; + Category category; + Post post; + String token; + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private CategoryRepository categoryRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private UserService userService; + + @BeforeEach + void setUp() { + user = User.builder() + .email("test@naver.com") + .userName("test") + .password("$2a$10$1dmE40BM1RD2lUg.9ss24eGs.4.iNYq1PwXzqKBfIXNRbKCKliqbG") // testpw + .build(); + user.setPermission(Permission.ROLE_USER); + userRepository.save(user); + category = categoryRepository.save(Category.builder() + .user(user) + .name("test") + .colorCode("#e7f5ff") + .build()); + post = postRepository.save(Post.builder() + .content("post1") + .selectedDate(LocalDate.now()) + .score(88).build()); + + final UserLoginResponseDto loginResponse = userService.authenticate(user.getEmail(), "testpw"); + token = loginResponse.getToken(); + } + + @Test + @DisplayName("카테고리를 생성할 수 있다.") + void createCategory() throws Exception { + final CategoryCreateRequestDto req = CategoryCreateRequestDto.builder() + .name("suebeen") + .colorCode("#d0ebff") // TODO: 예외 테스트 + .build(); + + mockMvc.perform(post("/api/v1/categories") + .contentType(MediaType.APPLICATION_JSON) + .header("token", token) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()) + .andDo(print()); + + } + + @Test + @DisplayName("카테고리를 수정 할 수 있다.") + void updateCategory() throws Exception { + final CategoryUpdateRequestDto req = CategoryUpdateRequestDto.builder() + .name("update") + .isPublic(false) + .colorCode("#d0ebdf") + .build(); + + mockMvc.perform(put("/api/v1/categories/{categoryId}", category.getCategoryId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andDo(print()); + } + + @Test + @DisplayName("카테고리를 삭제할 수 있다.") + void deleteCategory() throws Exception { + mockMvc.perform(delete("/api/v1/categories/{categoryId}", category.getCategoryId())) + .andExpect(status().isNoContent()) + .andDo(print()); + + } + + @Test + @DisplayName("유저의 모든 카테고리 정보를 조회할 수 있다.") + void findAllCategoryByUser() throws Exception { + mockMvc.perform(get("/api/v1/categories", user.getUserId()) + .header("token", token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()); + + } + + @Test + @DisplayName("유저의 대시보드를 조회할 수 있다.") + void getCategoryDashboard() throws Exception { + mockMvc.perform(get("/api/v1/categories/dashboard") + .param("userId", String.valueOf(user.getUserId())) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()); + + } +} \ No newline at end of file diff --git a/src/test/java/org/ahpuh/surf/category/service/CategoryServiceTest.java b/src/test/java/org/ahpuh/surf/category/service/CategoryServiceTest.java new file mode 100644 index 00000000..8ff89b44 --- /dev/null +++ b/src/test/java/org/ahpuh/surf/category/service/CategoryServiceTest.java @@ -0,0 +1,181 @@ +package org.ahpuh.surf.category.service; + +import org.ahpuh.surf.category.converter.CategoryConverter; +import org.ahpuh.surf.category.dto.CategoryCreateRequestDto; +import org.ahpuh.surf.category.dto.CategoryDetailResponseDto; +import org.ahpuh.surf.category.dto.CategoryResponseDto; +import org.ahpuh.surf.category.dto.CategoryUpdateRequestDto; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@Transactional +class CategoryServiceTest { + + @Autowired + CategoryService categoryService; + + @Autowired + CategoryRepository categoryRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + CategoryConverter categoryConverter; + + Category category; + + User user; + + @BeforeEach + void setUp() { + user = userRepository.save(User.builder() + .password("password") + .email("suebeen@gmail.com") + .userName("name") + .build()); + category = categoryRepository.save(Category.builder() + .user(user) + .name("test") + .colorCode("#e7f5ff") + .build()); + } + + @Test + @DisplayName("카테고리를 생성할 수 있다.") + void createCategoryTest() { + // given + final CategoryCreateRequestDto createRequestDto = CategoryCreateRequestDto.builder() + .name(category.getName()) + .colorCode(category.getColorCode()) + .build(); + + // when + categoryService.createCategory(user.getUserId(), createRequestDto); + + // then + assertAll( + () -> Assertions.assertThat(categoryRepository.findAll().size()).isEqualTo(2), + () -> Assertions.assertThat(categoryRepository.findAll().get(1).getName()).isEqualTo(createRequestDto.getName()), + () -> Assertions.assertThat(categoryRepository.findAll().get(1).getIsPublic()).isTrue(), + () -> Assertions.assertThat(categoryRepository.findAll().get(1).getColorCode()).isEqualTo(createRequestDto.getColorCode()) + ); + } + + @Test + @DisplayName("카테고리를 수정할 수 있다.") + void updateCategoryTest() { + // given + final CategoryUpdateRequestDto updateRequestDto = CategoryUpdateRequestDto.builder() + .name("update test") + .isPublic(false) + .colorCode("#d0ebff") + .build(); + + // when + categoryService.updateCategory(category.getCategoryId(), updateRequestDto); + + // then + assertAll( + () -> Assertions.assertThat(categoryRepository.findAll().get(0).getName()).isEqualTo(updateRequestDto.getName()), + () -> Assertions.assertThat(categoryRepository.findAll().get(0).getIsPublic()).isFalse(), + () -> Assertions.assertThat(categoryRepository.findAll().get(0).getColorCode()).isEqualTo(updateRequestDto.getColorCode()) + ); + } + + @Test + @DisplayName("카테고리를 삭제할 수 있다.") + void deleteCategoryTest() { + // given + final Long id = category.getCategoryId(); + + // when + categoryService.deleteCategory(id); + + // then + assertThat(categoryRepository.findAll().size(), is(0)); + } + + @Test + @DisplayName("사용자의 모든 카테고리 정보를 조회할 수 있다.") + void findAllCategoryByUserTest() { + // given + final Category newCategory = categoryRepository.save(Category.builder() + .user(user) + .name("test2") + .colorCode("#e7f5df") + .build()); + final Long id = user.getUserId(); + + // when + final List categories = categoryService.findAllCategoryByUser(id); + + // then + assertAll( + () -> Assertions.assertThat(categories.size()).isEqualTo(2), + () -> Assertions.assertThat(categories.get(0).getCategoryId()).isEqualTo(category.getCategoryId()), + () -> Assertions.assertThat(categories.get(1).getCategoryId()).isEqualTo(newCategory.getCategoryId()) + ); + } + + @Test + @DisplayName("사용자의 대시보드를 조회할 수 있다.") + void getCategoryDashboardTest() { + // given + final Category newCategory = categoryRepository.save(Category.builder() + .user(user) + .name("test2") + .colorCode("#e7f5df") + .build()); + + final Post post1 = postRepository.save(Post.builder() + .content("post1") + .selectedDate(LocalDate.now()) + .score(88).build()); + + final Post post2 = postRepository.save(Post.builder() + .content("post2") + .selectedDate(LocalDate.now()) + .score(43).build()); + + newCategory.addPost(post1); + newCategory.addPost(post2); + + final Long id = user.getUserId(); + + // when + final List categories = categoryService.getCategoryDashboard(id); + + // then + assertAll( + () -> Assertions.assertThat(categories.size()).isEqualTo(2), + () -> Assertions.assertThat(categories.get(0).getPostCount()).isZero(), + () -> Assertions.assertThat(categories.get(0).getAverageScore()).isZero() +// 테스트 통과x post가 생성될 때 post, user에 모두 추가되지 않음 ! +// () -> Assertions.assertThat(categories.get(1).getPostCount()).isEqualTo(2), +// () -> Assertions.assertThat(categories.get(1).getAverageScore()).isEqualTo(65) + ); + } +} diff --git a/src/test/java/org/ahpuh/surf/config/MockAwsS3Service.java b/src/test/java/org/ahpuh/surf/config/MockAwsS3Service.java new file mode 100644 index 00000000..138e4eeb --- /dev/null +++ b/src/test/java/org/ahpuh/surf/config/MockAwsS3Service.java @@ -0,0 +1,92 @@ +package org.ahpuh.surf.config; + +import lombok.extern.slf4j.Slf4j; +import org.ahpuh.surf.common.s3.S3Service; +import org.ahpuh.surf.common.s3.S3ServiceImpl.FileStatus; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Objects; + +@TestConfiguration +@Slf4j +public class MockAwsS3Service { + + @Bean + public S3Service s3Service() { + return new S3Service() { + + final String[] PERMISSION_IMG_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "tif", "ico", "svg", "bmp", "webp", "tiff", "jfif"}; + final String[] PERMISSION_FILE_EXTENSIONS = {"doc", "docx", "xls", "xlsx", "hwp", "pdf", "txt", "md", "ppt", "pptx", "key"}; + + public String uploadUserImg(final MultipartFile profilePhoto) throws IOException { + if (exist(profilePhoto)) { + return uploadImg(profilePhoto); + } + return null; + } + + public FileStatus uploadPostFile(final MultipartFile file) throws IOException { + if (exist(file)) { + String fileUrl = uploadFile(file); + if (fileUrl != null) { + return new FileStatus(fileUrl, "file"); + } + + fileUrl = uploadImg(file); + if (fileUrl != null) { + return new FileStatus(fileUrl, "img"); + } + } + return null; + } + + public String uploadImg(final MultipartFile file) throws IOException { + final String fileName = file.getOriginalFilename(); + final String extension = Objects.requireNonNull(fileName).split("\\.")[1]; + + if (invalidImageExtension(extension)) { + log.info("{}은(는) 지원하지 않는 확장자입니다.", extension); + return null; + } + return "mock"; + } + + public String uploadFile(final MultipartFile file) throws IOException { + final String fileName = file.getOriginalFilename(); + final String extension = Objects.requireNonNull(fileName).split("\\.")[1]; + + if (invalidFileExtension(extension)) { + return null; + } + return "mock"; + } + + public boolean exist(final MultipartFile file) { + return !file.isEmpty(); + } + + public boolean invalidImageExtension(final String extension) { + for (final String permissionExtension : PERMISSION_IMG_EXTENSIONS) { + if (extension.equals(permissionExtension)) { + return false; + } + } + return true; + } + + public boolean invalidFileExtension(final String extension) { + for (final String permissionExtension : PERMISSION_FILE_EXTENSIONS) { + if (extension.equals(permissionExtension)) { + return false; + } + } + return true; + } + + }; + } + +} diff --git a/src/test/java/org/ahpuh/surf/follow/controller/FollowControllerTest.java b/src/test/java/org/ahpuh/surf/follow/controller/FollowControllerTest.java new file mode 100644 index 00000000..1d0c8209 --- /dev/null +++ b/src/test/java/org/ahpuh/surf/follow/controller/FollowControllerTest.java @@ -0,0 +1,191 @@ +package org.ahpuh.surf.follow.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.ahpuh.surf.follow.entity.Follow; +import org.ahpuh.surf.follow.repository.FollowRepository; +import org.ahpuh.surf.user.controller.UserController; +import org.ahpuh.surf.user.dto.UserLoginRequestDto; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@SpringBootTest +class FollowControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private FollowRepository followRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private UserController userController; + + private User user1; + private User user2; + private User user3; + private Long userId1; + private Long userId2; + private Long userId3; + private String token; + + @BeforeEach + void setUp() { + user1 = userRepository.save(User.builder() + .email("user1@naver.com") + .userName("name") + .password("$2a$10$1dmE40BM1RD2lUg.9ss24eGs.4.iNYq1PwXzqKBfIXNRbKCKliqbG") // testpw + .build()); + userId1 = user1.getUserId(); + user2 = userRepository.save(User.builder() + .email("user2@naver.com") + .userName("name") + .password("$2a$10$1dmE40BM1RD2lUg.9ss24eGs.4.iNYq1PwXzqKBfIXNRbKCKliqbG") // testpw + .build()); + userId2 = user2.getUserId(); + user3 = userRepository.save(User.builder() + .email("user3@naver.com") + .userName("name") + .password("$2a$10$1dmE40BM1RD2lUg.9ss24eGs.4.iNYq1PwXzqKBfIXNRbKCKliqbG") // testpw + .build()); + userId3 = user3.getUserId(); + + final UserLoginRequestDto userJoinRequest = UserLoginRequestDto.builder() + .email("user1@naver.com") + .password("testpw") + .build(); + token = userController.login(userJoinRequest) + .getBody() + .getToken(); + } + + @Test + @DisplayName("팔로우를 할 수 있다.") + @Transactional + void testFollow() throws Exception { + // When + mockMvc.perform(post("/api/v1/follow") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userId2)) + .header("token", token)) + .andExpect(status().isCreated()) + .andDo(print()); + + // Then + final List allFollow = followRepository.findAll(); + assertAll("afterFollow", + () -> assertThat(allFollow.size(), is(1)), + () -> assertThat(allFollow.get(0).getUser(), is(user1)), + () -> assertThat(allFollow.get(0).getFollowedUser(), is(user2)) + ); + } + + @Test + @DisplayName("언팔로우를 할 수 있다.") + @Transactional + void testUnfollow() throws Exception { + // Given + followRepository.save(Follow.builder() + .user(user1) + .followedUser(user2) + .build()); + + final List follows = followRepository.findAll(); + assertAll("beforeUnfollow", + () -> assertThat(follows.size(), is(1)), + () -> assertThat(follows.get(0).getUser(), is(user1)), + () -> assertThat(follows.get(0).getFollowedUser(), is(user2)) + ); + + // When + mockMvc.perform(delete("/api/v1/follow/{userId}", userId2) + .contentType(MediaType.APPLICATION_JSON) + .header("token", token)) + .andExpect(status().isNoContent()) + .andDo(print()); + + // Then + assertThat(followRepository.findAll().size(), is(0)); + } + + @Test + @DisplayName("특정 user를 팔로우 한 user 목록을 조회할 수 있다.") + @Transactional + void testFindFollowerList() throws Exception { + // Given + followRepository.save(Follow.builder() + .user(user1) + .followedUser(user2) + .build()); + followRepository.save(Follow.builder() + .user(user1) + .followedUser(user3) + .build()); + + final List allFollow = followRepository.findAll(); + assertAll("user1이 user2, user3을 팔로우", + () -> assertThat(allFollow.size(), is(2)), + () -> assertThat(allFollow.get(0).getUser(), is(user1)), + () -> assertThat(allFollow.get(0).getFollowedUser(), is(user2)), + () -> assertThat(allFollow.get(1).getUser(), is(user1)), + () -> assertThat(allFollow.get(1).getFollowedUser(), is(user3)) + ); + + // When, Then + mockMvc.perform(get("/api/v1/users/{userId}/followers", userId2) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()); + } + + @Test + @DisplayName("특정 user가 팔로잉 한 user 목록을 조회할 수 있다.") + @Transactional + void testFollowingList() throws Exception { + // Given + followRepository.save(Follow.builder() + .user(user1) + .followedUser(user2) + .build()); + followRepository.save(Follow.builder() + .user(user1) + .followedUser(user3) + .build()); + + final List allFollow = followRepository.findAll(); + assertAll("user1이 user2, user3을 팔로우", + () -> assertThat(allFollow.size(), is(2)), + () -> assertThat(allFollow.get(0).getUser(), is(user1)), + () -> assertThat(allFollow.get(0).getFollowedUser(), is(user2)), + () -> assertThat(allFollow.get(1).getUser(), is(user1)), + () -> assertThat(allFollow.get(1).getFollowedUser(), is(user3)) + ); + + // When, Then + mockMvc.perform(get("/api/v1/users/{userId}/following", userId1) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/ahpuh/surf/like/controller/LikeControllerTest.java b/src/test/java/org/ahpuh/surf/like/controller/LikeControllerTest.java new file mode 100644 index 00000000..54ffebc1 --- /dev/null +++ b/src/test/java/org/ahpuh/surf/like/controller/LikeControllerTest.java @@ -0,0 +1,152 @@ +package org.ahpuh.surf.like.controller; + +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.like.entity.Like; +import org.ahpuh.surf.like.repository.LikeRepository; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.user.controller.UserController; +import org.ahpuh.surf.user.dto.UserLoginRequestDto; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@SpringBootTest +class LikeControllerTest { + + User user1; + User user2; + Long userId1; + Long userId2; + String userToken1; + Post post1; + Long postId1; + @Autowired + private MockMvc mockMvc; + @Autowired + private UserController userController; + @Autowired + private UserRepository userRepository; + @Autowired + private CategoryRepository categoryRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private LikeRepository likeRepository; + + @BeforeEach + void setUp() { + // user1, user2 회원가입 후 userId 반환 + user1 = userRepository.save(User.builder() + .email("user1@naver.com") + .userName("name") + .password("$2a$10$1dmE40BM1RD2lUg.9ss24eGs.4.iNYq1PwXzqKBfIXNRbKCKliqbG") // testpw + .build()); + userId1 = user1.getUserId(); + user2 = userRepository.save(User.builder() + .email("user2@naver.com") + .userName("name") + .password("$2a$10$1dmE40BM1RD2lUg.9ss24eGs.4.iNYq1PwXzqKBfIXNRbKCKliqbG") // testpw + .build()); + userId2 = user2.getUserId(); + + // user1 로그인 후 토큰 발급 + userToken1 = userController.login(UserLoginRequestDto.builder() + .email("user1@naver.com") + .password("testpw") + .build()) + .getBody() + .getToken(); + + // user2가 카테고리 생성 + final Category category1 = categoryRepository.save(Category.builder() + .user(user2) + .name("category 1") + .colorCode("#000000") + .build()); + + // user2가 post 생성 + post1 = postRepository.save(Post.builder() + .user(user2) + .category(category1) + .selectedDate(LocalDate.now()) + .content("content") + .score(80) + .build()); + postId1 = post1.getPostId(); + } + + @Test + @DisplayName("게시글 좋아요를 할 수 있다.") + @Transactional + void testLike() throws Exception { + // Given + assertThat(likeRepository.findAll().size(), is(0)); + + // When + mockMvc.perform(post("/api/v1/posts/{postId}/like", postId1) + .contentType(MediaType.APPLICATION_JSON) + .header("token", userToken1)) + .andExpect(status().isOk()) + .andDo(print()); + + // Then + final List likes = likeRepository.findAll(); + assertAll("afterLikePost", + () -> assertThat(likes.size(), is(1)), + () -> assertThat(likes.get(0).getUser(), is(user1)), + () -> assertThat(likes.get(0).getPost().getPostId(), is(postId1)) + ); + + } + + @Test + @DisplayName("게시글 좋아요 취소를 할 수 있다.") + @Transactional + void testUnlike() throws Exception { + // Given + likeRepository.save(Like.builder() + .user(user1) + .post(post1) + .build()); + + final List likes = likeRepository.findAll(); + assertAll("beforeUnlikePost", + () -> assertThat(likes.size(), is(1)), + () -> assertThat(likes.get(0).getUser(), is(user1)), + () -> assertThat(likes.get(0).getPost(), is(post1)) + ); + + // When + mockMvc.perform(delete("/api/v1/posts/{postId}/unlike/{likeId}", postId1, likes.get(0).getLikeId()) + .contentType(MediaType.APPLICATION_JSON) + .header("token", userToken1)) + .andExpect(status().isNoContent()) + .andDo(print()); + + // Then + assertThat(likeRepository.findAll().size(), is(0)); + } + +} \ No newline at end of file diff --git a/src/test/java/org/ahpuh/surf/post/PostTest.java b/src/test/java/org/ahpuh/surf/post/PostTest.java new file mode 100644 index 00000000..4c1a0fda --- /dev/null +++ b/src/test/java/org/ahpuh/surf/post/PostTest.java @@ -0,0 +1,148 @@ +package org.ahpuh.surf.post; + +import org.ahpuh.surf.category.dto.CategorySimpleDto; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.post.dto.PostCountDto; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.post.service.PostService; +import org.ahpuh.surf.user.controller.UserController; +import org.ahpuh.surf.user.dto.UserJoinRequestDto; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class PostTest { + + private Long userId2; + private Category category2; + private Category category3; + private int year; + + @Autowired + private UserController userController; + @Autowired + private UserRepository userRepository; + @Autowired + private CategoryRepository categoryRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private PostService postService; + + @BeforeEach + void setUp() { + year = 2021; + + final Long userId1 = saveUser("test1@naver.com", "test1"); + userId2 = saveUser("test2@naver.com", "test2"); + + final User user1 = userRepository.getById(userId1); + final User user2 = userRepository.getById(userId2); + + final Category category1 = saveCategory(user1, "category 1"); + category2 = saveCategory(user2, "category 2"); + category3 = saveCategory(user2, "category 3"); + + // post 생성 + savePost(user1, category1, LocalDate.now(), "content111", 0); + + savePost(user2, category3, LocalDate.of(2020, 12, 12), "content5", 90); + savePost(user2, category3, LocalDate.of(year, 12, 31), "content6", 50); + savePost(user2, category3, LocalDate.of(year, 1, 1), "content7", 100); + + savePost(user2, category2, LocalDate.of(year, 12, 10), "content1", 80); + savePost(user2, category2, LocalDate.of(2022, 12, 23), "content2", 90); + savePost(user2, category2, LocalDate.of(year, 12, 31), "content3", 50); + savePost(user2, category2, LocalDate.of(year, 12, 4), "content4", 100); + } + + @Test + @DisplayName("해당년도 게시글 개수 정보 조회") + @Transactional + void getCountsPerDayWithYear() { + // when + final List response = postService.getCountsPerDayWithYear(year, userId2); + + // then + assertAll( + () -> assertThat(response.size()).isEqualTo(4), + () -> assertThat(response.get(0).getDate()).isEqualTo(LocalDate.of(year, 1, 1)), + () -> assertThat(response.get(0).getCount()).isEqualTo(1), + () -> assertThat(response.get(2).getDate()).isEqualTo(LocalDate.of(year, 12, 10)), + () -> assertThat(response.get(2).getCount()).isEqualTo(1), + () -> assertThat(response.get(3).getDate()).isEqualTo(LocalDate.of(year, 12, 31)), + () -> assertThat(response.get(3).getCount()).isEqualTo(2) + ); + } + + @Test + @DisplayName("일년치 게시글 점수 조회") + @Transactional + void getScoresWithCategoryByUserId() { + // when + final List response = postService.getScoresWithCategoryByUserId(userId2); + + final CategorySimpleDto categorySimpleDto1 = response.get(0); + final CategorySimpleDto categorySimpleDto2 = response.get(1); + + // then + assertAll( + () -> assertThat(response.size()).isEqualTo(2), + + () -> assertThat(categorySimpleDto1.getCategoryId()).isEqualTo(category2.getCategoryId()), + () -> assertThat(categorySimpleDto1.getPostScores().size()).isEqualTo(4), + () -> assertThat(categorySimpleDto1.getPostScores().get(0).getY()).isEqualTo(100), + () -> assertThat(categorySimpleDto1.getPostScores().get(1).getY()).isEqualTo(80), + () -> assertThat(categorySimpleDto1.getPostScores().get(2).getY()).isEqualTo(50), + () -> assertThat(categorySimpleDto1.getPostScores().get(3).getY()).isEqualTo(90), + + () -> assertThat(categorySimpleDto2.getCategoryId()).isEqualTo(category3.getCategoryId()), + () -> assertThat(categorySimpleDto2.getPostScores().size()).isEqualTo(3), + () -> assertThat(categorySimpleDto2.getPostScores().get(0).getY()).isEqualTo(90), + () -> assertThat(categorySimpleDto2.getPostScores().get(1).getY()).isEqualTo(100), + () -> assertThat(categorySimpleDto2.getPostScores().get(2).getY()).isEqualTo(50) + ); + } + + private Long saveUser(final String email, final String pw) { + return userController.join(UserJoinRequestDto.builder() + .email(email) + .password(pw) + .userName("name") + .build()) + .getBody(); + } + + private Category saveCategory(final User user, final String categoryName) { + return categoryRepository.save(Category.builder() + .user(user) + .name(categoryName) + .build()); + } + + private void savePost(final User user, final Category category, final LocalDate selectedDate, final String content, + final int score) { + postRepository.save(Post.builder() + .user(user) + .category(category) + .selectedDate(selectedDate) + .content(content) + .score(score) + .build()); + } + +} diff --git a/src/test/java/org/ahpuh/surf/post/repository/PostRepositoryTest.java b/src/test/java/org/ahpuh/surf/post/repository/PostRepositoryTest.java new file mode 100644 index 00000000..591a09b8 --- /dev/null +++ b/src/test/java/org/ahpuh/surf/post/repository/PostRepositoryTest.java @@ -0,0 +1,181 @@ +package org.ahpuh.surf.post.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.follow.entity.Follow; +import org.ahpuh.surf.follow.repository.FollowRepository; +import org.ahpuh.surf.post.dto.ExploreDto; +import org.ahpuh.surf.post.dto.QExploreDto; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.user.controller.UserController; +import org.ahpuh.surf.user.dto.UserJoinRequestDto; +import org.ahpuh.surf.user.dto.UserLoginRequestDto; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +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.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import java.time.LocalDate; +import java.util.List; + +import static org.ahpuh.surf.follow.entity.QFollow.follow; +import static org.ahpuh.surf.post.entity.QPost.post; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class PostRepositoryTest { + + User user1; + Long userId1; + Long userId2; + Long userId3; + String userToken1; + @Autowired + private UserController userController; + @Autowired + private UserRepository userRepository; + @Autowired + private CategoryRepository categoryRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private FollowRepository followRepository; + @Autowired + private EntityManager entityManager; + + @BeforeEach + void setUp() { + // user1, user2, user3 회원가입 후 userId 반환 + userId1 = userController.join(UserJoinRequestDto.builder() + .email("test1@naver.com") + .password("test1") + .userName("name") + .build()) + .getBody(); + userId2 = userController.join(UserJoinRequestDto.builder() + .email("test2@naver.com") + .password("test2") + .userName("name") + .build()) + .getBody(); + userId3 = userController.join(UserJoinRequestDto.builder() + .email("test3@naver.com") + .password("test3") + .userName("name") + .build()) + .getBody(); + + // user1 로그인 후 토큰 발급 + userToken1 = userController.login(UserLoginRequestDto.builder() + .email("test1@naver.com") + .password("test1") + .build()) + .getBody() + .getToken(); + + user1 = userRepository.getById(userId1); + final User user2 = userRepository.getById(userId2); + final User user3 = userRepository.getById(userId3); + + // user2, user3 카테고리 생성 + final Category category1 = categoryRepository.save(Category.builder() + .user(user2) + .name("category 1") + .build()); + final Category category2 = categoryRepository.save(Category.builder() + .user(user3) + .name("category 2") + .build()); + + // post 생성 + postRepository.save(Post.builder() + .user(user2) + .category(category1) + .selectedDate(LocalDate.of(2021, 12, 12)) + .content("content1") + .score(80) + .build()); + postRepository.save(Post.builder() + .user(user3) + .category(category2) + .selectedDate(LocalDate.of(2021, 2, 1)) + .content("content2") + .score(80) + .build()); + postRepository.save(Post.builder() + .user(user1) + .category(category2) + .selectedDate(LocalDate.of(2021, 3, 3)) + .content("content4") + .score(80) + .build()); + postRepository.save(Post.builder() + .user(user2) + .category(category1) + .selectedDate(LocalDate.of(2021, 8, 8)) + .content("content3") + .score(80) + .build()); + + // Following : user1 -> user2, user3 + followRepository.save(Follow.builder() + .user(user1) + .followedUser(user2) + .build()); + followRepository.save(Follow.builder() + .user(user1) + .followedUser(user3) + .build()); + } + + @Test + @Transactional + void testQueryDsl() { + final JPAQueryFactory query = new JPAQueryFactory(entityManager); + final PageRequest page = PageRequest.of(0, 10); + final List posts = query + .select(new QExploreDto( + post.user.userId.as("userId"), + post.user.userName.as("userName"), + post.user.profilePhotoUrl.as("profilePhotoUrl"), + post.category.name.as("categoryName"), + post.category.colorCode.as("colorCode"), + post.postId.as("postId"), + post.content.as("content"), + post.score.as("score"), + post.imageUrl.as("imageUrl"), + post.fileUrl.as("fileUrl"), + post.selectedDate, + post.createdAt.as("createdAt") + )) + .from(post) + .leftJoin(follow).on(follow.user.userId.eq(userId1)) + .where(follow.followedUser.userId.eq(post.user.userId), post.isDeleted.eq(false)) + .groupBy(post.postId, follow.followId) + .orderBy(post.selectedDate.desc(), post.createdAt.desc()) + .limit(page.getPageSize()) + .fetch(); + + assertAll("follow한 사용자의 모든 posts by querydsl", + () -> assertThat(posts.size(), is(3)), + () -> assertThat(posts.get(0).getContent(), is("content1")), + () -> assertThat(posts.get(0).getUserId(), is(userId2)), + () -> assertThat(posts.get(1).getContent(), is("content3")), + () -> assertThat(posts.get(1).getUserId(), is(userId2)), + () -> assertThat(posts.get(2).getContent(), is("content2")), + () -> assertThat(posts.get(2).getUserId(), is(userId3)), + () -> assertThat(postRepository.findFollowingPosts(userId1, page).size(), is(3)) + ); + + ; + } + +} diff --git a/src/test/java/org/ahpuh/surf/post/service/PostServiceImplTest.java b/src/test/java/org/ahpuh/surf/post/service/PostServiceImplTest.java new file mode 100644 index 00000000..67fefb6f --- /dev/null +++ b/src/test/java/org/ahpuh/surf/post/service/PostServiceImplTest.java @@ -0,0 +1,124 @@ +package org.ahpuh.surf.post.service; + +import org.ahpuh.surf.category.entity.Category; +import org.ahpuh.surf.category.repository.CategoryRepository; +import org.ahpuh.surf.post.converter.PostConverter; +import org.ahpuh.surf.post.dto.PostRequestDto; +import org.ahpuh.surf.post.entity.Post; +import org.ahpuh.surf.post.repository.PostRepository; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PostServiceImplTest { + + @Mock + private PostConverter postConverter; + + @Mock + private PostRepository postRepository; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private PostServiceImpl postService; + + private Post post; + private Category category; + private User user; + + private Long postId; + private Long categoryId; + private String selectedDate; + private String content; + private int score; + + @BeforeEach + void setUp() { + user = User.builder() + .userName("ah-puh") + .email("aaa@gmail.com") + .password("pswd") + .build(); + + Mockito.lenient().when(userRepository.findById(1L)) + .thenReturn(Optional.of(user)); + + postId = 1L; + categoryId = 1L; + selectedDate = "2021-12-06"; + content = "어푸"; + score = 100; + + category = Category.builder().build(); + post = Post.builder() + .category(category) + .selectedDate(LocalDate.parse(selectedDate)) + .content(content) + .score(score) + .build(); + + Mockito.lenient().when(categoryRepository.findById(categoryId)) + .thenReturn(Optional.of(category)); + } + + @Test + @DisplayName("post 생성") + void create() { + // given + final Long userId = 1L; + final PostRequestDto request = PostRequestDto.builder() + .categoryId(categoryId) + .selectedDate(selectedDate) + .content(content) + .score(score) + .build(); + when(postConverter.toEntity(any(), any(), any(), any())) + .thenReturn(post); + when(postRepository.save(any(Post.class))) + .thenReturn(post); + + // when + final Long response = postService.create(userId, request, null); + + // then + assertAll( + () -> verify(postRepository, times(1)).save(any(Post.class)) + ); + } + + @Test + @DisplayName("존재하지 않는 id로 post 조회") + void throwException_getPostById() { + // given + final Long invalidPostId = -1L; + when(postRepository.findById(invalidPostId)) + .thenReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> postService.readOne(1L, invalidPostId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Post with given id not found. Invalid id is " + invalidPostId); + } + +} diff --git a/src/test/java/org/ahpuh/surf/user/controller/UserControllerTest.java b/src/test/java/org/ahpuh/surf/user/controller/UserControllerTest.java new file mode 100644 index 00000000..429c78e5 --- /dev/null +++ b/src/test/java/org/ahpuh/surf/user/controller/UserControllerTest.java @@ -0,0 +1,208 @@ +package org.ahpuh.surf.user.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.ahpuh.surf.config.MockAwsS3Service; +import org.ahpuh.surf.user.dto.UserJoinRequestDto; +import org.ahpuh.surf.user.dto.UserLoginRequestDto; +import org.ahpuh.surf.user.dto.UserUpdateRequestDto; +import org.ahpuh.surf.user.entity.User; +import org.ahpuh.surf.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(MockAwsS3Service.class) +@AutoConfigureMockMvc +@SpringBootTest +class UserControllerTest { + + User user1; + Long userId1; + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private UserRepository userRepository; + @Autowired + private UserController userController; + + @BeforeEach + void setUp() { + user1 = userRepository.save(User.builder() + .email("test@naver.com") + .password("$2a$10$1dmE40BM1RD2lUg.9ss24eGs.4.iNYq1PwXzqKBfIXNRbKCKliqbG") // testpw + .userName("user1") + .build()); + userId1 = user1.getUserId(); + } + + @Test + @DisplayName("회원가입을 할 수 있다.") + @Transactional + void testJoin() throws Exception { + // Given + final UserJoinRequestDto req = UserJoinRequestDto.builder() + .email("test1@naver.com") + .password("test111") + .userName("name") + .build(); + + // When + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()) + .andDo(print()); + + // Then + assertAll("userJoin", + () -> assertThat(userRepository.findAll().size(), is(2)), + () -> assertThat(userRepository.findAll().get(1).getEmail(), is("test1@naver.com")) + ); + } + + @Test + @DisplayName("로그인 할 수 있다.") + @Transactional + void testLogin() throws Exception { + // Given + final UserLoginRequestDto req = UserLoginRequestDto.builder() + .email("test@naver.com") + .password("testpw") + .build(); + + // When Then + mockMvc.perform(post("/api/v1/users/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andDo(print()); + } + + @Test + @DisplayName("회원정보를 조회할 수 있다.") + @Transactional + void testFindUserInfo() throws Exception { + mockMvc.perform(get("/api/v1/users/{userId}", userId1) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()); + } + + @Test + @DisplayName("회원정보를 수정할 수 있다.") + @Transactional + void testUpdateUser() throws Exception { + // Given + assertAll("beforeUpdate", + () -> assertThat(user1.getUserName(), is("user1")), + () -> assertThat(user1.getAboutMe(), is(nullValue())), + () -> assertThat(user1.getUrl(), is(nullValue())), + () -> assertThat(user1.getAccountPublic(), is(true)) + ); + + final UserLoginRequestDto loginReq = UserLoginRequestDto.builder() + .email("test@naver.com") + .password("testpw") + .build(); + final String token = Objects.requireNonNull(userController.login(loginReq).getBody()).getToken(); + + // When + final UserUpdateRequestDto updateReq = UserUpdateRequestDto.builder() + .userName("수정된 name") + .password(null) + .url("내 블로그 주소") + .aboutMe("수정된 소개글") + .accountPublic(false) + .build(); + final MockMultipartFile request = new MockMultipartFile( + "request", + "request.txt", + "application/json", + objectMapper.writeValueAsBytes(updateReq)); + + final MockMultipartFile file = new MockMultipartFile( + "file", + "imagefile.jpeg", + "image/jpeg", + "<>".getBytes()); + + final MockMultipartHttpServletRequestBuilder builder = MockMvcRequestBuilders.multipart("/api/v1/users"); + builder.with(requestMethod -> { + requestMethod.setMethod("PUT"); + return requestMethod; + }); + + // 파일을 첨부하면 파일 url로 변경됨 (mock) + mockMvc.perform(builder + .file(request) + .file(file) + .header("token", token)) + .andExpect(status().isOk()) + .andDo(print()); + + // Then + final User user11 = userRepository.findAll().get(0); + assertAll("afterUpdate", + () -> assertThat(user11.getUserName(), is("수정된 name")), + () -> assertThat(user11.getUrl(), is("내 블로그 주소")), + () -> assertThat(user11.getAboutMe(), is("수정된 소개글")), + () -> assertThat(user11.getAccountPublic(), is(false)), + () -> assertThat(user11.getProfilePhotoUrl(), is("mock")) + ); + + // file을 첨부하지 않으면 파일 url을 변경하지 않음 + mockMvc.perform(builder + .file(request) + .file(new MockMultipartFile("file", null, null, new byte[0])) + .header("token", token)) + .andExpect(status().isOk()) + .andDo(print()); + assertThat(user1.getProfilePhotoUrl(), is("mock")); + + } + + @Test + @DisplayName("회원을 삭제(softDelete) 할 수 있다.") + @Transactional + void testDeleteUser() throws Exception { + // Given + final UserLoginRequestDto req = UserLoginRequestDto.builder() + .email("test@naver.com") + .password("testpw") + .build(); + final String token = Objects.requireNonNull(userController.login(req).getBody()).getToken(); + + // When + mockMvc.perform(delete("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .header("token", token)) + .andExpect(status().isNoContent()) + .andDo(print()); + + // Then + assertThat(userRepository.findAll().size(), is(0)); + } + +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..14193484 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,36 @@ +spring: + application: + name: surf + h2: + console: + enabled: true + path: /h2-console + jpa: + database: h2 + open-in-view: true + show-sql: true + hibernate: + ddl-auto: create-drop + use-new-id-generator-mappings: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect +server: + port: 8080 +jwt: + header: token + issuer: ahpuh + client-secret: ${JWT_CLIENT_SECRET} + expiry-seconds: 2592000 +cloud: + aws: + credentials: + accessKey: mock + secretKey: mock + s3: + bucket: mock + region: + static: ap-northeast-2 + stack: + auto: false