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 extends GrantedAuthority> 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