diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 53ba9c8d..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: log4u-build -on: -# push: -# branches: -# - develop # dev 브랜치 push - pull_request: - branches: - - main # main pr - - develop # develop pr - types: [ opened, synchronize, reopened ] -jobs: - build: - name: Build and analyze - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: 21 - distribution: 'zulu' # Alternative distribution options are available - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} - restore-keys: ${{ runner.os }}-gradle - - name: Cache SonarCloud packages - uses: actions/cache@v4 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - name: Build and analyze - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - DB_URL: ${{ secrets.DB_URL }} # Database URL - DB_USERNAME: ${{ secrets.DB_USERNAME }} # Database username - DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # Database password - run: | - chmod +x ./gradlew - ./gradlew build jacocoTestReport sonar --info -Dsonar.branch.name=${{ github.ref_name }} \ No newline at end of file diff --git a/.github/workflows/ci.cd.prod.yml b/.github/workflows/ci.cd.prod.yml index ce009d23..37491ec4 100644 --- a/.github/workflows/ci.cd.prod.yml +++ b/.github/workflows/ci.cd.prod.yml @@ -7,24 +7,14 @@ on: types: - closed workflow_dispatch: # 수동 실행 가능 - - # 병합됐을 때 jobs: - deploy: + Build: runs-on: ubuntu-latest steps: # 체크아웃 - uses: actions/checkout@v4 - # AWS 인증 (IAM 사용자 Access Key, Secret Key 활용) - - 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 }} - # Gradle 권한 설정 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -36,6 +26,22 @@ jobs: java-version: 21 distribution: 'temurin' + # application.yml 운영 환경 용 생성 + - name: Make application.yml + run: | + mkdir -p ./src/main/resources + chmod -R 777 ./src/main/resources + cd ./src/main/resources + + touch ./application.yml + touch ./application-prod.yml + touch ./application-prod-secret.yml + + echo "${{ secrets.PROD_COMMON }}" | base64 --decode > ./application.yml + echo "${{ secrets.PROD }}" | base64 --decode > ./application-prod.yml + echo "${{ secrets.PROD_SECRET }}" | base64 --decode > ./application-prod-secret.yml + + # Gradle cache 설정 - name: Cache Gradle packages uses: actions/cache@v4 @@ -44,12 +50,56 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} restore-keys: ${{ runner.os }}-gradle - # Gradle build (우선 Test 제외) - - name: Build with Gradle - uses: gradle/gradle-build-action@0d13054264b0bb894ded474f08ebb30921341cee + - name: MySQL 컨테이너 실행 + run: | + docker run --name log4u-mysql \ + -e MYSQL_ROOT_PASSWORD=root \ + -e MYSQL_DATABASE=log4u \ + -e MYSQL_USER=dev \ + -e MYSQL_PASSWORD=devcos4-team08 \ + -d \ + -p 3307:3306 \ + mysql:8.0.33 + + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + DB_URL: jdbc:mysql://localhost:3307/log4u + DB_USERNAME: dev + DB_PASSWORD: devcos4-team08 + + # 테스트용으로 dev 프로필 사용(시크릿 제외하고 prod 와 동일) + run: | + chmod +x ./gradlew + ./gradlew build jacocoTestReport -Pprofile=dev + + - name: Docker MySQL 종료 및 제거 + run: | + docker stop log4u-mysql + docker rm log4u-mysql + + # AWS 인증 (IAM 사용자 Access Key, Secret Key 활용) + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 with: - arguments: clean build -x test + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + - name: Secret 파일 수동 생성 + run: | + mkdir -p ./src/main/resources + echo "${{ secrets.PROD_SECRET }}" | base64 --decode > ./src/main/resources/application-prod-secret.yml + + mkdir -p ./deploy-package/src/main/resources + rsync -av --exclude='deploy-package' ./ ./deploy-package + cp ./src/main/resources/application-prod-secret.yml ./deploy-package/src/main/resources/application-prod-secret.yml + + - name: 빌드 결과 수동 생성 + run: | + mkdir -p ./deploy-package/build/libs + cp build/libs/Log4U-0.0.1-SNAPSHOT.jar ./deploy-package/build/libs/ # 빌드 결과물을 S3 버킷에 업로드 - name: Upload to AWS S3 @@ -57,16 +107,32 @@ jobs: aws deploy push \ --application-name ${{ secrets.CODE_DEPLOY_APP_NAME }} \ --ignore-hidden-files \ - --s3-location s3://$S3_BUCKET_NAME/$GITHUB_SHA.zip \ - --source . + --s3-location s3://${{ secrets.S3_BUCKET_NAME }}/prod/${{ github.sha }}.zip \ + --source ./deploy-package # S3 버킷에 있는 파일을 대상으로 CodeDeploy 실행 - - name: Deploy to AWS EC2 from S3 + - name: Deploy to AWS EC2 from S3g run: | aws deploy create-deployment \ --application-name ${{ secrets.CODE_DEPLOY_APP_NAME }} \ --deployment-config-name CodeDeployDefault.AllAtOnce \ --deployment-group-name ${{ secrets.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \ - --s3-location bucket=$S3_BUCKET_NAME,key=$GITHUB_SHA.zip,bundleType=zip - - + --s3-location bucket=${{ secrets.S3_BUCKET_NAME }},key=prod/${{ github.sha }}.zip,bundleType=zip + +# 향후 빌드 파일 개선용 주석 +# - name: 필요한 파일 수동 생성 +# run: | +# mkdir -p ./deploy-package/src/main/resources +# +# # appspec.yml 복사 +# cp ./appspec.yml ./deploy-package/ +# +# # scripts 디렉토리 및 내부 .sh 파일 복사 +# cp -r ./scripts/*.sh ./deploy-package/ +# +# # yml 복사 +# cp ./src/main/resources/application*.yml ./deploy-package/ +# +# # jar 복사 +# cp ./build/libs/Log4U-0.0.1-SNAPSHOT.jar ./deploy-package/ + diff --git a/.github/workflows/code-review-claude.yml b/.github/workflows/code-review-claude.yml index 819e2cb0..9b71ab5e 100644 --- a/.github/workflows/code-review-claude.yml +++ b/.github/workflows/code-review-claude.yml @@ -2,7 +2,10 @@ name: Code Review from Claude on: pull_request: - types: [opened, synchronize] + # dev에 pr 할때만 리뷰 + branches: + - develop + types: [ opened, synchronize ] permissions: contents: read diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml deleted file mode 100644 index d464f0bb..00000000 --- a/.github/workflows/cr.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Code Review - -permissions: - contents: read - pull-requests: write - -on: - pull_request: - types: [ opened, reopened, synchronize ] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: anc95/ChatGPT-CodeReview@main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - # optional - LANGUAGE: Korean - PROMPT: | - "경험 많은 시니어 개발자로서, 다음 변경사항들에 대해 전체적이고 간결한 코드 리뷰를 수행해주세요. - - 리뷰 지침: - 1. 모든 변경사항을 종합적으로 검토하고, 가장 중요한 문제점이나 개선사항에만 집중하세요. - 2. 파일별로 개별 리뷰를 하지 말고, 전체 변경사항에 대한 통합된 리뷰를 제공하세요. - 3. 각 주요 이슈에 대해 간단한 설명과 구체적인 개선 제안을 제시하세요. - 4. 개선 제안에는 실제 코드 예시를 포함하세요. 단, 코드 예시는 제공한 코드와 연관된 코드여야 합니다. - 5. 사소한 스타일 문제나 개인적 선호도는 무시하세요. - 6. 심각한 버그, 성능 문제, 또는 보안 취약점이 있는 경우에만 언급하세요. - 7. 전체 리뷰는 간결하게 유지하세요. - 8. 변경된 부분만 집중하여 리뷰하고, 이미 개선된 코드를 다시 지적하지 마세요. - 9. 기존에 이미 개선된 사항(예: 중복 코드 제거를 위한 함수 생성)을 인식하고 이를 긍정적으로 언급하세요. - 10. 변경된 파일과 관련된 다른 파일들에 미칠 수 있는 영향을 분석하세요. - - 리뷰 형식: - - 개선된 사항: [이미 개선된 부분에 대한 긍정적 언급] - - 주요 이슈 (있는 경우에만): - 1. [문제 설명] - - 제안: [개선 방안 설명] - ```java - // 수정된 코드 예시 - ``` - 2. ... - - 관련 파일에 대한 영향 분석: - [변경된 파일과 관련된 다른 파일들에 미칠 수 있는 잠재적 영향 설명] - - 전반적인 의견: [1-2문장으로 요약] - - 변경된 파일들: - - 무시할 파일 패턴: - /node_modules,*.md" - IGNORE_PATTERNS: /node_modules,*.md # Regex pattern to ignore files, separated by comma \ No newline at end of file diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 00000000..84106bf7 --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,72 @@ +name: log4u-dev-build +on: + pull_request: + branches: + - develop # develop pr + types: [ opened, synchronize, reopened ] + workflow_dispatch: +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'zulu' # Alternative distribution options are available + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + + - name: MySQL 컨테이너 실행 + run: | + docker run --name log4u-mysql \ + -e MYSQL_ROOT_PASSWORD=root \ + -e MYSQL_DATABASE=log4u \ + -e MYSQL_USER=dev \ + -e MYSQL_PASSWORD=devcos4-team08 \ + -d \ + -p 3307:3306 \ + mysql:8.0.33 + + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + DB_URL: jdbc:mysql://localhost:3307/log4u + DB_USERNAME: dev + DB_PASSWORD: devcos4-team08 + + # dev 프로필 사용 + run: | + chmod +x ./gradlew + # 소나클라우드 임시 비활성화 ./gradlew build jacocoTestReport sonar --info -Pprofile=dev -Dsonar.branch.name=${{ github.ref_name }} + ./gradlew build jacocoTestReport -Pprofile=dev + + - name: Docker MySQL 종료 및 제거 + run: | + docker stop log4u-mysql + docker rm log4u-mysql + + - name: Upload Test Report + uses: actions/upload-artifact@v4 + with: + name: problems-report + path: build/reports/problems/problems-report.html diff --git a/.gitignore b/.gitignore index c2065bc2..6aafc94a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ +/src/main/resources/application-secret.yml +/src/main/resources/application-prod-secret.yml +/src/main/resources/application-dev-secret.yml diff --git a/README.md b/README.md new file mode 100644 index 00000000..77b01835 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +## 개발 환경 설정 + +[AWS API SERVER](http://ec2-13-209-127-186.ap-northeast-2.compute.amazonaws.com) +--- + +* 루트 디렉토리(WEB3_4_Log4U_BE)에서 다음 명령 실행 +* 개발용 MYSQL 빌드 + +``` +# 이미지 빌드 +cd docker +docker build -t log4u-mysql . + +# 최초 실행 1(볼륨 존재) +docker run -d --name log4u-mysql -p 3307:3306 -v {file}:/var/lib/mysql log4u-mysql + +# 최초 실행 2(볼륨 없이) +docker run -d --name log4u-mysql -p 3307:3306 log4u-mysql + +# 이미 존재할 경우 +docker start log4u-mysql + +``` diff --git a/appspec.yml b/appspec.yml index b9e0d804..71013de2 100644 --- a/appspec.yml +++ b/appspec.yml @@ -3,7 +3,7 @@ os: linux files: - source: / - destination: /home/ubuntu/build + destination: /home/ubuntu/app overwrite: yes permissions: @@ -13,16 +13,11 @@ permissions: group: ubuntu hooks: - AfterInstall: + ApplicationStop: - location: scripts/stop.sh timeout: 2000 runas: ubuntu - AfterInstall: - - location: scripts/docker-start.sh - timeout: 2000 - runas: ubuntu - ApplicationStart: - location: scripts/deploy.sh timeout: 2000 diff --git a/build.gradle.kts b/build.gradle.kts index 32ef1f81..cf99c8f6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,8 +28,8 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") -// implementation("org.springframework.boot:spring-boot-starter-oauth2-client") -// implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-redis") @@ -44,19 +44,22 @@ dependencies { testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") -// JWT 관련 + // JWT 관련 implementation("io.jsonwebtoken:jjwt-api:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") -// Swagger + // Swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.1") -// Querydsl - implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta") - implementation("com.querydsl:querydsl-apt:5.1.0:jakarta") - annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta") - annotationProcessor("jakarta.persistence:jakarta.persistence-api:3.1.0") + // QueryDSL + implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") + annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + // mysql + runtimeOnly("com.mysql:mysql-connector-j") } tasks.withType { @@ -78,11 +81,24 @@ checkstyle { sonar { properties { - property("sonar.projectKey", "sapiens2000-dev_simple-sns") - property("sonar.organization", "sapiens2000-dev") + property("sonar.projectKey", "prgrms-web-devcourse-final-project_WEB3_4_Log4U_BE") + property("sonar.organization", "prgrms-web-devcourse-final-project") property("sonar.host.url", "https://sonarcloud.io") - property("sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml") - property("sonar.java.checkstyle.reportPaths", "build/reports/checkstyle/main.xml") - property("sonar.branch.name", System.getenv("BRANCH_NAME") ?: "main") + + // Jacoco 리포트가 존재하는지 확인 후 적용 + val jacocoReportPath = file("build/reports/jacoco/test/jacocoTestReport.xml") + if (jacocoReportPath.exists()) { + property("sonar.coverage.jacoco.xmlReportPaths", jacocoReportPath.absolutePath) + } + + // Checkstyle 리포트가 존재하는지 확인 후 적용 + val checkstyleReportPath = file("build/reports/checkstyle/main.xml") + if (checkstyleReportPath.exists()) { + property("sonar.java.checkstyle.reportPaths", checkstyleReportPath.absolutePath) + } + + // 환경 변수 `BRANCH_NAME`이 없을 경우 "main"으로 설정 + val branchName = System.getenv("BRANCH_NAME") ?: "main" + property("sonar.branch.name", branchName) } } \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..6aae9966 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +# MYSQL +FROM mysql:8.0.33 + +# 변수 설정 +ENV TZ=Asia/Seoul \ + MYSQL_ROOT_PASSWORD=root \ + MYSQL_USER=dev \ + MYSQL_PASSWORD=devcos4-team08 \ + MYSQL_DATABASE=log4u + +# 데이터 파일을 컨테이너에 연결 +VOLUME ["/var/lib/mysql"] + +# MySQL 3306 포트 +EXPOSE 3306 + +# MYSQL 실행 +CMD ["mysqld"] diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 00000000..8a701010 --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,24 @@ +## 📌 PR 제목 + + +## ✨ 변경 사항 + +- 변경 사항 1 +- 변경 사항 2 +- 변경 사항 3 + +## 🔍 변경 이유 + + +## ✅ 체크리스트 + +- [ ] 코드가 정상적으로 동작하는지 확인 +- [ ] 관련 테스트 코드 작성 및 통과 여부 확인 +- [ ] 문서화(README 등) 필요 여부 확인 및 반영 +- [ ] 리뷰어가 알아야 할 사항 추가 설명 + +## 📸 스크린샷 (선택) + + +## 📌 참고 사항 + diff --git a/.github/scripts/deploy.sh b/scripts/deploy.sh similarity index 71% rename from .github/scripts/deploy.sh rename to scripts/deploy.sh index 27dd5ca3..bc5f59ef 100644 --- a/.github/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash -PROJECT_ROOT="/home/ubuntu/build" +PROJECT_ROOT="/home/ubuntu/app" JAR_FILE="$PROJECT_ROOT/spring-log4u.jar" APP_LOG="$PROJECT_ROOT/application.log" @@ -11,11 +11,11 @@ TIME_NOW=$(date +%c) # build 파일 복사 echo "$TIME_NOW > $JAR_FILE 파일 복사" >> $DEPLOY_LOG -cp $PROJECT_ROOT/build/libs/*.jar $JAR_FILE +cp $PROJECT_ROOT/build/libs/Log4U-0.0.1-SNAPSHOT.jar $JAR_FILE # jar 파일 실행 echo "$TIME_NOW > $JAR_FILE 파일 실행" >> $DEPLOY_LOG -nohup java -jar $JAR_FILE > $APP_LOG 2> $ERROR_LOG & +nohup java -Dspring.profiles.active="prod, prod-secret" -jar $JAR_FILE > $APP_LOG 2> $ERROR_LOG & CURRENT_PID=$(pgrep -f $JAR_FILE) echo "$TIME_NOW > 실행된 프로세스 아이디 $CURRENT_PID 입니다." >> $DEPLOY_LOG diff --git a/.github/scripts/stop.sh b/scripts/stop.sh similarity index 93% rename from .github/scripts/stop.sh rename to scripts/stop.sh index ba4c09b6..a860b1f6 100644 --- a/.github/scripts/stop.sh +++ b/scripts/stop.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -PROJECT_ROOT="/home/ubuntu/build" +PROJECT_ROOT="/home/ubuntu/app" JAR_FILE="$PROJECT_ROOT/spring-log4u.jar" DEPLOY_LOG="$PROJECT_ROOT/deploy.log" diff --git a/src/main/java/com/example/log4u/Log4UApplication.java b/src/main/java/com/example/log4u/Log4UApplication.java index d54d143b..60e12406 100644 --- a/src/main/java/com/example/log4u/Log4UApplication.java +++ b/src/main/java/com/example/log4u/Log4UApplication.java @@ -8,8 +8,8 @@ @SpringBootApplication public class Log4UApplication { - public static void main(String[] args) { - SpringApplication.run(Log4UApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(Log4UApplication.class, args); + } } diff --git a/src/main/java/com/example/log4u/common/config/QueryDslConfig.java b/src/main/java/com/example/log4u/common/config/QueryDslConfig.java new file mode 100644 index 00000000..4bc58909 --- /dev/null +++ b/src/main/java/com/example/log4u/common/config/QueryDslConfig.java @@ -0,0 +1,23 @@ +package com.example.log4u.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; + +@Configuration +public class QueryDslConfig { + + private final EntityManager entityManager; + + public QueryDslConfig(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/log4u/common/config/SecurityConfig.java b/src/main/java/com/example/log4u/common/config/SecurityConfig.java new file mode 100644 index 00000000..55cc0313 --- /dev/null +++ b/src/main/java/com/example/log4u/common/config/SecurityConfig.java @@ -0,0 +1,102 @@ +package com.example.log4u.common.config; + +import java.util.Collections; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; + +import com.example.log4u.common.constants.UrlConstants; +import com.example.log4u.common.oauth2.handler.OAuth2AuthenticationSuccessHandler; +import com.example.log4u.common.oauth2.jwt.JwtAuthenticationFilter; +import com.example.log4u.common.oauth2.jwt.JwtLogoutFilter; +import com.example.log4u.common.oauth2.jwt.JwtUtil; +import com.example.log4u.common.oauth2.repository.RefreshTokenRepository; +import com.example.log4u.common.oauth2.service.CustomOAuth2UserService; +import com.example.log4u.domain.user.service.UserService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +public class SecurityConfig { + private final JwtUtil jwtUtil; + private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final UserService userService; + private final RefreshTokenRepository refreshTokenRepository; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws + Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtUtil, userService); + } + + @Bean + public JwtLogoutFilter jwtLogoutFilter() { + return new JwtLogoutFilter(jwtUtil, refreshTokenRepository); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) //csrf 비활성화 + .formLogin(AbstractHttpConfigurer::disable) //폼 로그인 방식 disable + .httpBasic(AbstractHttpConfigurer::disable); // HTTP Basic 인증 방식 disable + + // oauth2 설정 + http + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig + .userService(customOAuth2UserService)) + .successHandler(oAuth2AuthenticationSuccessHandler) + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userService), OAuth2LoginAuthenticationFilter.class) + .addFilterBefore(new JwtLogoutFilter(jwtUtil, refreshTokenRepository), LogoutFilter.class); + + //경로별 인가 작업 + http + .authorizeHttpRequests(auth -> auth + // 소셜 로그인 경로 + .requestMatchers("/oauth2/**").permitAll() + // Swagger UI 관련 경로 (swagger-ui.html 추가) + .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated()); + + //세션 설정 : STATELESS + http + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // CORS 설정 + .cors((corsCustomizer -> corsCustomizer.configurationSource(request -> { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Collections.singletonList(UrlConstants.FRONT_URL)); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); + configuration.setExposedHeaders(Collections.singletonList("access")); + configuration.setExposedHeaders(Collections.singletonList("refresh")); + return configuration; + }))); + return http.build(); + } +} diff --git a/src/main/java/com/example/log4u/common/constants/TokenConstants.java b/src/main/java/com/example/log4u/common/constants/TokenConstants.java new file mode 100644 index 00000000..a2231119 --- /dev/null +++ b/src/main/java/com/example/log4u/common/constants/TokenConstants.java @@ -0,0 +1,16 @@ +package com.example.log4u.common.constants; + +public class TokenConstants { + // 토큰 타입 + public static final String ACCESS_TOKEN = "access"; + public static final String REFRESH_TOKEN = "refresh"; + + // 토큰 속 키 값 + public static final String USER_ID_KEY = "userId"; + public static final String TOKEN_TYPE_KEY = "token"; + public static final String USER_NAME_KEY = "name"; + public static final String USER_ROLE_KEY = "role"; + + private TokenConstants() { + } +} diff --git a/src/main/java/com/example/log4u/common/constants/UrlConstants.java b/src/main/java/com/example/log4u/common/constants/UrlConstants.java new file mode 100644 index 00000000..804a7f3f --- /dev/null +++ b/src/main/java/com/example/log4u/common/constants/UrlConstants.java @@ -0,0 +1,9 @@ +package com.example.log4u.common.constants; + +public class UrlConstants { + public static final String FRONT_URL = "http://localhost:3000"; + + // checkstyle 경고 제거 + private UrlConstants() { + } +} diff --git a/src/main/java/com/example/log4u/common/dto/PageResponse.java b/src/main/java/com/example/log4u/common/dto/PageResponse.java new file mode 100644 index 00000000..b2c34097 --- /dev/null +++ b/src/main/java/com/example/log4u/common/dto/PageResponse.java @@ -0,0 +1,60 @@ +package com.example.log4u.common.dto; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +public record PageResponse( + List content, + PageInfo pageInfo +) { + // 오프셋 기반 (검색용) + public static PageResponse of(Page page) { + return new PageResponse<>( + page.getContent(), + PageInfo.of(page) + ); + } + + // 커서 기반 (무한 스크롤용) + public static PageResponse of(Slice slice, Long nextCursor) { + return new PageResponse<>( + slice.getContent(), + PageInfo.of(slice, nextCursor) + ); + } + + public record PageInfo( + Integer page, + int size, + Long totalElements, + Integer totalPages, + boolean hasNext, + Long nextCursor + ) { + // Page용 팩토리 메서드 + public static PageInfo of(Page page) { + return new PageInfo( + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + null + ); + } + + // Slice용 팩토리 메서드 + public static PageInfo of(Slice slice, Long nextCursor) { + return new PageInfo( + null, + slice.getSize(), + null, + null, + slice.hasNext(), + nextCursor + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/log4u/common/entity/BaseEntity.java b/src/main/java/com/example/log4u/common/entity/BaseEntity.java index 6f5adb62..cf6bf0a2 100644 --- a/src/main/java/com/example/log4u/common/entity/BaseEntity.java +++ b/src/main/java/com/example/log4u/common/entity/BaseEntity.java @@ -23,6 +23,4 @@ abstract public class BaseEntity { @LastModifiedDate @Column(nullable = false) private LocalDateTime updatedAt; - - private String deleteYn = "N"; -} +} \ No newline at end of file diff --git a/src/main/java/com/example/log4u/common/oauth2/controller/OAuth2Controller.java b/src/main/java/com/example/log4u/common/oauth2/controller/OAuth2Controller.java new file mode 100644 index 00000000..5ab48af9 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/controller/OAuth2Controller.java @@ -0,0 +1,99 @@ +package com.example.log4u.common.oauth2.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.log4u.common.oauth2.jwt.JwtUtil; +import com.example.log4u.common.oauth2.repository.RefreshTokenRepository; +import com.example.log4u.common.oauth2.service.RefreshTokenService; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/oauth2") +public class OAuth2Controller { + + private final JwtUtil jwtUtil; + private final RefreshTokenService refreshTokenService; + private final RefreshTokenRepository refreshTokenRepository; + + @GetMapping("/token/reissue") + public ResponseEntity reissue( + HttpServletRequest request, + HttpServletResponse response + ) { + // 리프레시 토큰 추출 + String refresh = null; + String access = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + } + if (cookie.getName().equals("access")) { + access = cookie.getValue(); + } + } + + if (refresh == null) { + // 리프레시 토큰이 없는 경우 + return new ResponseEntity<>("잘못된 요청입니다..", HttpStatus.BAD_REQUEST); + } + + // 리프레시 토큰 만료 체크 + try { + jwtUtil.isExpired(refresh); + } catch (ExpiredJwtException e) { + return new ResponseEntity<>("리프레시 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED); + } + + // 토큰이 refresh인지 확인 (발급시 페이로드에 명시) + String category = jwtUtil.getTokenType(refresh); + if (!category.equals("refresh")) { + return new ResponseEntity<>("잘못된 토큰입니다.", HttpStatus.BAD_REQUEST); + } + + createNewTokens(response, access, refresh); + return new ResponseEntity<>(HttpStatus.OK); + } + + private void createNewTokens(HttpServletResponse response, String access, String refresh) { + // 기존 리프레시 토큰 삭제 + refreshTokenRepository.deleteByRefresh(refresh); + + Long userId = jwtUtil.getUserId(access); + String role = jwtUtil.getRole(access); + String name = jwtUtil.getName(access); + + String newAccessToken = jwtUtil.createJwt("access", userId, name, role, 600000L); + String newRefreshToken = jwtUtil.createJwt("refresh", userId, name, role, 600000L); + + response.addCookie(createCookie("refresh", newRefreshToken)); + response.addCookie(createCookie("access", newAccessToken)); + + // 새 리프레시 토큰 저장 + refreshTokenService.saveRefreshToken( + userId, + name, + refresh + ); + + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60 * 60 * 60); + //cookie.setSecure(true); + cookie.setPath("/"); + cookie.setHttpOnly(true); + return cookie; + } +} diff --git a/src/main/java/com/example/log4u/common/oauth2/dto/CustomOAuth2User.java b/src/main/java/com/example/log4u/common/oauth2/dto/CustomOAuth2User.java new file mode 100644 index 00000000..9247b91d --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/dto/CustomOAuth2User.java @@ -0,0 +1,52 @@ +package com.example.log4u.common.oauth2.dto; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.example.log4u.domain.user.dto.UserCreateRequestDto; +import com.example.log4u.domain.user.entity.User; + +public class CustomOAuth2User implements OAuth2User { + private final UserCreateRequestDto userCreateRequestDto; + + public CustomOAuth2User(UserCreateRequestDto userCreateRequestDto) { + this.userCreateRequestDto = userCreateRequestDto; + } + + public CustomOAuth2User(User user) { + this.userCreateRequestDto = UserCreateRequestDto.fromEntity(user); + } + + @Override + public Map getAttributes() { + return Map.of(); + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + collection.add((GrantedAuthority)userCreateRequestDto::role); + return collection; + } + + @Override + public String getName() { + return userCreateRequestDto.name(); + } + + public String getRole() { + return userCreateRequestDto.role(); + } + + public String getProviderId() { + return userCreateRequestDto.providerId(); + } + + public Long getUserId() { + return userCreateRequestDto.userId(); + } +} diff --git a/src/main/java/com/example/log4u/common/oauth2/dto/GoogleResponseDto.java b/src/main/java/com/example/log4u/common/oauth2/dto/GoogleResponseDto.java new file mode 100644 index 00000000..55b5a3d4 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/dto/GoogleResponseDto.java @@ -0,0 +1,45 @@ +package com.example.log4u.common.oauth2.dto; + +import java.util.Map; + +import com.example.log4u.domain.user.entity.SocialType; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class GoogleResponseDto implements OAuth2Response { + + private final Map attribute; + + @Override + public SocialType getSocialType() { + return SocialType.GOOGLE; + } + + @Override + public String getProviderId() { + return attribute.get("sub").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + // 구글은 이름이 닉네임 + @Override + public String getName() { + return attribute.get("name").toString(); + } + + @Override + public String getNickname() { + return ""; + } + + @Override + public String getProfileImage() { + return attribute.get("picture").toString(); + } + +} diff --git a/src/main/java/com/example/log4u/common/oauth2/dto/KakaoResponseDto.java b/src/main/java/com/example/log4u/common/oauth2/dto/KakaoResponseDto.java new file mode 100644 index 00000000..2a030e56 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/dto/KakaoResponseDto.java @@ -0,0 +1,52 @@ +package com.example.log4u.common.oauth2.dto; + +import java.util.Map; + +import com.example.log4u.domain.user.entity.SocialType; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class KakaoResponseDto implements OAuth2Response { + + private static final String KAKAO_ACCOUNT = "kakao_account"; + + @Override + public String getNickname() { + return ""; + } + + @Override + public String getProfileImage() { + // kakao_account.profile.profile_image_url 형태로 응답 + Map account = (Map)attribute.get(KAKAO_ACCOUNT); + Map profile = (Map)account.get("profile"); + return (String)profile.get("profile_image_url"); + } + + private final Map attribute; + + @Override + public SocialType getSocialType() { + return SocialType.KAKAO; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getEmail() { + Map account = (Map)attribute.get(KAKAO_ACCOUNT); + return (String)account.get("email"); + } + + @Override + public String getName() { + Map account = (Map)attribute.get(KAKAO_ACCOUNT); + Map profile = (Map)account.get("profile"); + return profile.get("nickname").toString(); + } + +} diff --git a/src/main/java/com/example/log4u/common/oauth2/dto/NaverResponseDto.java b/src/main/java/com/example/log4u/common/oauth2/dto/NaverResponseDto.java new file mode 100644 index 00000000..093e2b93 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/dto/NaverResponseDto.java @@ -0,0 +1,45 @@ +package com.example.log4u.common.oauth2.dto; + +import java.util.Map; + +import com.example.log4u.domain.user.entity.SocialType; + +public class NaverResponseDto implements OAuth2Response { + + private final Map attribute; + + public NaverResponseDto(Map attribute) { + this.attribute = (Map)attribute.get("response"); + } + + @Override + public SocialType getSocialType() { + return SocialType.NAVER; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return attribute.get("name").toString(); + } + + @Override + public String getNickname() { + return attribute.get("nickname").toString(); + } + + @Override + public String getProfileImage() { + return attribute.get("profile_image").toString(); + } + +} diff --git a/src/main/java/com/example/log4u/common/oauth2/dto/OAuth2Response.java b/src/main/java/com/example/log4u/common/oauth2/dto/OAuth2Response.java new file mode 100644 index 00000000..38baf17a --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/dto/OAuth2Response.java @@ -0,0 +1,17 @@ +package com.example.log4u.common.oauth2.dto; + +import com.example.log4u.domain.user.entity.SocialType; + +public interface OAuth2Response { + SocialType getSocialType(); + + String getProviderId(); + + String getEmail(); + + String getName(); + + String getNickname(); + + String getProfileImage(); +} diff --git a/src/main/java/com/example/log4u/common/oauth2/entity/RefreshToken.java b/src/main/java/com/example/log4u/common/oauth2/entity/RefreshToken.java new file mode 100644 index 00000000..9b050155 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/entity/RefreshToken.java @@ -0,0 +1,32 @@ +package com.example.log4u.common.oauth2.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity(name = "refresh_token") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Setter + private String name; + + @Column(nullable = false) + @Setter + private String refresh; + + @Column(nullable = false) + @Setter + private String expiration; +} diff --git a/src/main/java/com/example/log4u/common/oauth2/handler/OAuth2AuthenticationSuccessHandler.java b/src/main/java/com/example/log4u/common/oauth2/handler/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 00000000..96f6e81d --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/handler/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,104 @@ +package com.example.log4u.common.oauth2.handler; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.log4u.common.oauth2.dto.CustomOAuth2User; +import com.example.log4u.common.oauth2.jwt.JwtUtil; +import com.example.log4u.common.oauth2.service.RefreshTokenService; +import com.example.log4u.domain.user.entity.User; +import com.example.log4u.domain.user.repository.UserRepository; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final UserRepository userRepository; + private final RefreshTokenService refreshTokenService; + private final JwtUtil jwtUtil; + + private static final String MAIN_PAGE = "http://localhost:3000/"; + private static final String PROFILE_CREATE_PAGE = "http://localhost:3000/profile"; + private static final String LOGIN_PAGE = "http://localhost:3000/login"; + + private static final String ACCESS_TOKEN_KEY = "access"; + private static final String REFRESH_TOKEN_KEY = "refresh"; + + @Value("${jwt.access-token-expire-time-seconds}") + private long accessTokenValidityInSeconds; + + @Value("${jwt.refresh-token-expire-time-seconds}") + private long refreshTokenValidityInSeconds; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + //OAuth2User + CustomOAuth2User customOAuth2User = (CustomOAuth2User)authentication.getPrincipal(); + Optional existUser = userRepository.findByProviderId(customOAuth2User.getProviderId()); + Long userId = existUser.map(User::getUserId).orElse(null); + String name = customOAuth2User.getName(); + + setCookieAndSaveRefreshToken(response, userId, authentication, name); + redirectTo(response, customOAuth2User); + } + + private void setCookieAndSaveRefreshToken( + HttpServletResponse response, + Long userId, + Authentication authentication, + String name + ) { + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // 쿠키 생성 + String access = jwtUtil.createJwt(ACCESS_TOKEN_KEY, userId, name, role, accessTokenValidityInSeconds); + String refresh = jwtUtil.createJwt(REFRESH_TOKEN_KEY, userId, name, role, refreshTokenValidityInSeconds); + + // 리프레시 토큰 DB 저장 + refreshTokenService.saveRefreshToken(null, name, refresh); + + response.addCookie(createCookie(ACCESS_TOKEN_KEY, access)); + response.addCookie(createCookie(REFRESH_TOKEN_KEY, refresh)); + response.setStatus(HttpStatus.OK.value()); + } + + private void redirectTo(HttpServletResponse response, CustomOAuth2User customOAuth2User) throws IOException { + String redirectUrl = switch (customOAuth2User.getRole()) { + case "ROLE_GUEST" -> PROFILE_CREATE_PAGE; + case "ROLE_USER" -> MAIN_PAGE; + default -> LOGIN_PAGE; + }; + response.sendRedirect(redirectUrl); + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60 * 60 * 60); + //cookie.setSecure(true); + cookie.setPath("/"); + cookie.setHttpOnly(true); + return cookie; + } + +} diff --git a/src/main/java/com/example/log4u/common/oauth2/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/log4u/common/oauth2/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..a2ff0613 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,125 @@ +package com.example.log4u.common.oauth2.jwt; + +import java.io.IOException; +import java.io.PrintWriter; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.log4u.common.oauth2.dto.CustomOAuth2User; +import com.example.log4u.domain.user.service.UserService; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + private final UserService userService; + private static final String ACCESS_TOKEN_EXPIRED_JSON_MSG = "{\"message\": \"토큰이 존재하지 않습니다.\"}"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + // 필터 스킵이 필요한지 확인 + if (shouldSkipFilter(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = extractAccessTokenFromCookie(request); + // 엑세스 토큰이 없을 경우 통과해서 발급 절차 + if (accessToken == null) { + filterChain.doFilter(request, response); + return; + } + + log.debug("필터에서 추출한 엑세스 토큰: " + accessToken + "\n"); + + // 토큰 유효성 검사(실패 시 바로 리턴 + if (!validateTokenExpiration(response, accessToken)) { + return; + } + + addUserToContextHolder(accessToken); + filterChain.doFilter(request, response); + } + + private boolean shouldSkipFilter(String requestUri) { + return requestUri.matches("^/login(/.*)?$") + || requestUri.matches("^/oauth2(/.*)?$") + || requestUri.matches("^/swagger-ui(/.*)?$") + || requestUri.matches("^/v3/api-docs(/.*)?$"); // OpenAPI 문서 예외 처리 + } + + private String extractAccessTokenFromCookie(HttpServletRequest request) { + // 쿠키에서 access 토큰 추출 + String accessToken = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + if (cookie.getName().equals("access")) { + accessToken = cookie.getValue(); + } + } + return accessToken; + } + + private boolean validateTokenExpiration( + HttpServletResponse response, + String accessToken + ) throws IOException { + // 토큰 만료 확인 , 만료 시 다음 필터로 넘기지 않음(재발급 필요) + try { + log.debug("만료확인체크" + "\n"); + log.debug("유저 ID : " + jwtUtil.getUserId(accessToken) + "\n"); + log.debug("role : " + jwtUtil.getRole(accessToken) + "\n"); + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + PrintWriter writer = response.getWriter(); + writer.print(ACCESS_TOKEN_EXPIRED_JSON_MSG); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + // access 토큰 인지 확인 (발급 시 페이로드에 명시) + if (!jwtUtil.getTokenType(accessToken).equals("access")) { + PrintWriter writer = response.getWriter(); + writer.print(ACCESS_TOKEN_EXPIRED_JSON_MSG); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + // 유효성 검사 성공 + return true; + } + + private void addUserToContextHolder(String accessToken) { + // 토큰에서 id 추출 + Long userId = jwtUtil.getUserId(accessToken); + CustomOAuth2User customOAuth2User = new CustomOAuth2User(userService.getUserById(userId)); + log.debug("필터에서 추출한 userId: " + userId); + log.debug("생성된 CustomOAuth2User ID: " + customOAuth2User.getUserId()); + + // security context holder 에 추가해줌 + Authentication oAuth2Token = new UsernamePasswordAuthenticationToken( + customOAuth2User, + null, + customOAuth2User.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(oAuth2Token); + } + +} diff --git a/src/main/java/com/example/log4u/common/oauth2/jwt/JwtLogoutFilter.java b/src/main/java/com/example/log4u/common/oauth2/jwt/JwtLogoutFilter.java new file mode 100644 index 00000000..4a60e31f --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/jwt/JwtLogoutFilter.java @@ -0,0 +1,148 @@ +package com.example.log4u.common.oauth2.jwt; + +import static com.example.log4u.common.constants.TokenConstants.*; + +import java.io.IOException; +import java.io.PrintWriter; + +import org.springframework.web.filter.GenericFilterBean; + +import com.example.log4u.common.oauth2.repository.RefreshTokenRepository; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JwtLogoutFilter extends GenericFilterBean { + + private final JwtUtil jwtUtil; + private final RefreshTokenRepository refreshTokenRepository; + private static final String REFRESH_TOKEN_EXPIRED_JSON_MSG = "{\"message\": \"토큰이 존재하지 않습니다.\"}"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws + IOException, + ServletException { + doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain); + } + + private void doFilter( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws IOException, ServletException { + + // url 이 logout 이 아닐 경우 필터 통과 + if (shouldSkipFilter(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + // POST 요청 아니면 통과 + if (!request.getMethod().equals("POST")) { + filterChain.doFilter(request, response); + return; + } + + // 리프레시 토큰 추출 + String refresh = extractRefreshTokenFromCookie(request); + + // 리프레시 토큰 유효성 검사 + if (!validateTokenExpiration(response, refresh)) { + return; + } + + // 로그아웃 진행 + logout(response, refresh); + } + + private boolean shouldSkipFilter(String requestUri) { + // logout 검사 + return !requestUri.matches("^\\/logout$") + || requestUri.matches("^/oauth2(/.*)?$") + || requestUri.matches("^/swagger-ui(/.*)?$")// Swagger UI 예외 처리 + || requestUri.matches("^/v3/api-docs(/.*)?$"); // OpenAPI 문서 예외 처리 + } + + private boolean validateTokenExpiration( + HttpServletResponse response, + String refresh + ) throws IOException { + // 리프레시 토큰 만료 체크 + if (refresh == null) { + PrintWriter writer = response.getWriter(); + writer.print(REFRESH_TOKEN_EXPIRED_JSON_MSG); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 만료 검사 + try { + jwtUtil.isExpired(refresh); + } catch (ExpiredJwtException e) { + PrintWriter writer = response.getWriter(); + writer.print(REFRESH_TOKEN_EXPIRED_JSON_MSG); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 토큰이 refresh 인지 확인 (발급시 페이로드에 명시) + String tokenType = jwtUtil.getTokenType(refresh); + if (!tokenType.equals(REFRESH_TOKEN)) { + PrintWriter writer = response.getWriter(); + writer.print(REFRESH_TOKEN_EXPIRED_JSON_MSG); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 리프레시 토큰이 DB에 없는 경우 + Boolean isExist = refreshTokenRepository.existsByRefresh(refresh); + if (Boolean.FALSE.equals(isExist)) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 유효성 검사 성공 + return true; + } + + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + // 리프레시 토큰 추출 + String refresh = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + if (cookie.getName().equals(REFRESH_TOKEN)) { + refresh = cookie.getValue(); + } + } + return refresh; + } + + public void logout(HttpServletResponse response, String refresh) { + // DB 에서 리프레시 토큰 제거 + refreshTokenRepository.deleteByRefresh(refresh); + // 쿠키 제거 + deleteCookie(response); + } + + public void deleteCookie(HttpServletResponse response) { + Cookie access = new Cookie("access", null); + Cookie refresh = new Cookie("refresh", null); + + access.setMaxAge(0); + access.setPath("/"); + refresh.setMaxAge(0); + refresh.setPath("/"); + + response.addCookie(access); + response.addCookie(refresh); + response.setStatus(HttpServletResponse.SC_OK); + } +} diff --git a/src/main/java/com/example/log4u/common/oauth2/jwt/JwtUtil.java b/src/main/java/com/example/log4u/common/oauth2/jwt/JwtUtil.java new file mode 100644 index 00000000..c28ff946 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/jwt/JwtUtil.java @@ -0,0 +1,100 @@ +package com.example.log4u.common.oauth2.jwt; + +import static com.example.log4u.common.constants.TokenConstants.*; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + + public JwtUtil(@Value("${jwt.secret}") String secret) { + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public Long getUserId(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get(USER_ID_KEY, Long.class); + } catch (ExpiredJwtException ex) { + return ex.getClaims().get(USER_ID_KEY, Long.class); + } + } + + public String getName(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get(USER_NAME_KEY, String.class); + } catch (ExpiredJwtException ex) { + return ex.getClaims().get(USER_NAME_KEY, String.class); + } + } + + public String getRole(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get("role", String.class); + } catch (ExpiredJwtException ex) { + return ex.getClaims().get(USER_ROLE_KEY, String.class); + } + } + + public String getTokenType(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get(TOKEN_TYPE_KEY, String.class); + } catch (ExpiredJwtException ex) { + return ex.getClaims().get(TOKEN_TYPE_KEY, String.class); + } + } + + public Boolean isExpired(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date()); + } + + public String createJwt(String tokenType, Long userId, String name, String role, Long expiredMs) { + return Jwts.builder() + .claim(TOKEN_TYPE_KEY, tokenType) + .claim(USER_ID_KEY, userId) + .claim(USER_NAME_KEY, name) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs * 1000)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/com/example/log4u/common/oauth2/repository/RefreshTokenRepository.java b/src/main/java/com/example/log4u/common/oauth2/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..9aefb1b4 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.example.log4u.common.oauth2.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.log4u.common.oauth2.entity.RefreshToken; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Boolean existsByRefresh(String refresh); + + void deleteByRefresh(String refresh); +} diff --git a/src/main/java/com/example/log4u/common/oauth2/service/CustomOAuth2UserService.java b/src/main/java/com/example/log4u/common/oauth2/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..fdacacaf --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/service/CustomOAuth2UserService.java @@ -0,0 +1,89 @@ +package com.example.log4u.common.oauth2.service; + +import java.util.Optional; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import com.example.log4u.common.oauth2.dto.CustomOAuth2User; +import com.example.log4u.common.oauth2.dto.GoogleResponseDto; +import com.example.log4u.common.oauth2.dto.KakaoResponseDto; +import com.example.log4u.common.oauth2.dto.NaverResponseDto; +import com.example.log4u.common.oauth2.dto.OAuth2Response; +import com.example.log4u.domain.user.dto.UserCreateRequestDto; +import com.example.log4u.domain.user.entity.User; +import com.example.log4u.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final UserRepository userRepository; + + /** + * OAuth2 인증 후 리소스 서버에서 받아온 아온 사용자 정보 처리 + *
    + *
  1. OAuth2UserService 를 통해 사용자 정보 조회
  2. + *
  3. CustomOAuth2User 반환(to security manager)
  4. + *
+ */ + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + // registrationId = 소셜 로그인 타입 + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + // 정보 가공 + OAuth2Response oAuth2Response = switch (registrationId) { + case "naver" -> new NaverResponseDto(oAuth2User.getAttributes()); + case "google" -> new GoogleResponseDto(oAuth2User.getAttributes()); + case "kakao" -> new KakaoResponseDto(oAuth2User.getAttributes()); + default -> throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인"); + }; + + // 정보 조회 + String providerId = oAuth2Response.getProviderId(); + Optional dbUser = userRepository.findByProviderId(providerId); + + // 첫 로그인이면 프로필 없으므로 우선 GUEST 설정 + if (dbUser.isEmpty()) { + return createUser(oAuth2Response); + } else { // DB의 유저 정보 갱신 + return updateUser(oAuth2Response, dbUser.get()); + } + } + + public CustomOAuth2User createUser(OAuth2Response oAuth2Response) { + UserCreateRequestDto userCreateRequestDto = UserCreateRequestDto.fromOAuth2Response( + oAuth2Response, + null, + "ROLE_GUEST" + ); + User user = UserCreateRequestDto.toEntity(userCreateRequestDto); + userRepository.save(user); + + UserCreateRequestDto afterSaveDto = UserCreateRequestDto.fromOAuth2Response( + oAuth2Response, + user.getUserId(), + "ROLE_GUEST" + ); + + return new CustomOAuth2User(afterSaveDto); + } + + public CustomOAuth2User updateUser(OAuth2Response oAuth2Response, User user) { + user.updateOauth2Profile(oAuth2Response); + userRepository.save(user); + + UserCreateRequestDto userCreateRequestDto = UserCreateRequestDto.fromOAuth2Response( + oAuth2Response, + user.getUserId(), + user.getRole() + ); + return new CustomOAuth2User(userCreateRequestDto); + } +} diff --git a/src/main/java/com/example/log4u/common/oauth2/service/RefreshTokenService.java b/src/main/java/com/example/log4u/common/oauth2/service/RefreshTokenService.java new file mode 100644 index 00000000..a6c9edb3 --- /dev/null +++ b/src/main/java/com/example/log4u/common/oauth2/service/RefreshTokenService.java @@ -0,0 +1,33 @@ +package com.example.log4u.common.oauth2.service; + +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.example.log4u.common.oauth2.entity.RefreshToken; +import com.example.log4u.common.oauth2.repository.RefreshTokenRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Value("${jwt.refresh-token-expire-time-seconds}") + private long refreshTokenValidityInSeconds; + + public void saveRefreshToken(Long userId, String name, String refresh) { + Date date = new Date(System.currentTimeMillis() + refreshTokenValidityInSeconds); + + RefreshToken refreshToken = new RefreshToken( + userId, + name, + refresh, + date.toString() + ); + refreshTokenRepository.save(refreshToken); + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/controller/CommentController.java b/src/main/java/com/example/log4u/domain/comment/controller/CommentController.java index b0abf960..764d631a 100644 --- a/src/main/java/com/example/log4u/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/log4u/domain/comment/controller/CommentController.java @@ -2,14 +2,18 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.example.log4u.common.dto.PageResponse; import com.example.log4u.domain.comment.dto.request.CommentCreateRequestDto; import com.example.log4u.domain.comment.dto.response.CommentCreateResponseDto; +import com.example.log4u.domain.comment.dto.response.CommentResponseDto; import com.example.log4u.domain.comment.service.CommentService; import io.swagger.v3.oas.annotations.tags.Tag; @@ -39,4 +43,14 @@ public ResponseEntity deleteComment(@PathVariable Long commentId) { commentService.deleteComment(userId, commentId); return ResponseEntity.noContent().build(); } + + @GetMapping("/{diaryId}") + public ResponseEntity> getCommentListByDiary( + @PathVariable Long diaryId, + @RequestParam(required = false) Long cursorCommentId, + @RequestParam(defaultValue = "5") int size + ) { + PageResponse response = commentService.getCommentListByDiary(diaryId, cursorCommentId, size); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/example/log4u/domain/comment/dto/response/CommentResponseDto.java b/src/main/java/com/example/log4u/domain/comment/dto/response/CommentResponseDto.java new file mode 100644 index 00000000..933dddaf --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/dto/response/CommentResponseDto.java @@ -0,0 +1,15 @@ +package com.example.log4u.domain.comment.dto.response; + +import com.example.log4u.domain.comment.entity.Comment; + +public record CommentResponseDto( + Long commentId, + String content +) { + public static CommentResponseDto of(Comment comment) { + return new CommentResponseDto( + comment.getCommentId(), + comment.getContent() + ); + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/entity/Comment.java b/src/main/java/com/example/log4u/domain/comment/entity/Comment.java index e1644a64..e332aef1 100644 --- a/src/main/java/com/example/log4u/domain/comment/entity/Comment.java +++ b/src/main/java/com/example/log4u/domain/comment/entity/Comment.java @@ -30,5 +30,6 @@ public class Comment extends BaseEntity { @Column(nullable = false) private Long diaryId; + @Column(nullable = false) private String content; } diff --git a/src/main/java/com/example/log4u/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/log4u/domain/comment/repository/CommentRepository.java index f499cf0d..8f90ad3f 100644 --- a/src/main/java/com/example/log4u/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/log4u/domain/comment/repository/CommentRepository.java @@ -1,9 +1,14 @@ package com.example.log4u.domain.comment.repository; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.example.log4u.domain.comment.entity.Comment; -public interface CommentRepository extends JpaRepository { - +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { } diff --git a/src/main/java/com/example/log4u/domain/comment/repository/CommentRepositoryCustom.java b/src/main/java/com/example/log4u/domain/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 00000000..3ef43ad3 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.example.log4u.domain.comment.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import com.example.log4u.domain.comment.entity.Comment; + +public interface CommentRepositoryCustom { + + Slice findByDiaryIdWithCursor(Long diaryId, Long cursorCommentId, Pageable pageable); +} diff --git a/src/main/java/com/example/log4u/domain/comment/repository/CommentRepositoryImpl.java b/src/main/java/com/example/log4u/domain/comment/repository/CommentRepositoryImpl.java new file mode 100644 index 00000000..b2fa98ba --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.example.log4u.domain.comment.repository; + +import static com.example.log4u.domain.comment.entity.QComment.*; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import com.example.log4u.domain.comment.entity.Comment; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Slice findByDiaryIdWithCursor(Long diaryId, Long cursorCommentId, Pageable pageable) { + List result = queryFactory + .selectFrom(comment) + .where( + comment.diaryId.eq(diaryId), + cursorCommentId != null ? comment.commentId.lt(cursorCommentId) : null + ) + .orderBy(comment.commentId.desc()) + .limit(pageable.getPageSize() + 1) // 커서 기반 페이징 + .fetch(); + + boolean hasNext = result.size() > pageable.getPageSize(); + List content = hasNext ? result.subList(0, pageable.getPageSize()) : result; + + return new SliceImpl<>(content, pageable, hasNext); + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/service/CommentService.java b/src/main/java/com/example/log4u/domain/comment/service/CommentService.java index aecd9722..53c9c392 100644 --- a/src/main/java/com/example/log4u/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/log4u/domain/comment/service/CommentService.java @@ -1,10 +1,18 @@ package com.example.log4u.domain.comment.service; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.example.log4u.common.dto.PageResponse; import com.example.log4u.domain.comment.dto.request.CommentCreateRequestDto; import com.example.log4u.domain.comment.dto.response.CommentCreateResponseDto; +import com.example.log4u.domain.comment.dto.response.CommentResponseDto; import com.example.log4u.domain.comment.entity.Comment; import com.example.log4u.domain.comment.exception.NotFoundCommentException; import com.example.log4u.domain.comment.exception.UnauthorizedAccessException; @@ -22,7 +30,7 @@ public class CommentService { @Transactional public CommentCreateResponseDto addComment(Long userId, CommentCreateRequestDto requestDto) { - checkDiaryExists(requestDto); + checkDiaryExists(requestDto.diaryId()); Comment comment = requestDto.toEntity(userId); commentRepository.save(comment); return CommentCreateResponseDto.of(comment); @@ -35,8 +43,8 @@ public void deleteComment(Long userId, Long commentId) { commentRepository.delete(comment); } - private void checkDiaryExists(CommentCreateRequestDto requestDto) { - diaryService.checkDiaryExists(requestDto.diaryId()); + private void checkDiaryExists(Long diaryId) { + diaryService.checkDiaryExists(diaryId); } private void validateCommentOwner(Long userId, Comment comment) { @@ -49,4 +57,18 @@ private Comment getComment(Long commentId) { return commentRepository.findById(commentId) .orElseThrow(NotFoundCommentException::new); } + + @Transactional(readOnly = true) + public PageResponse getCommentListByDiary(Long diaryId, Long cursorCommentId, int size) { + checkDiaryExists(diaryId); + Pageable pageable = PageRequest.of(0, size); + Slice slice = commentRepository.findByDiaryIdWithCursor(diaryId, cursorCommentId, pageable); + + List dtoList = slice.getContent().stream() + .map(CommentResponseDto::of) + .toList(); + + Long nextCursor = slice.hasNext() ? dtoList.getLast().commentId() : null; + return PageResponse.of(new SliceImpl<>(dtoList, pageable, slice.hasNext()), nextCursor); + } } diff --git a/src/main/java/com/example/log4u/domain/diary/SortType.java b/src/main/java/com/example/log4u/domain/diary/SortType.java new file mode 100644 index 00000000..4349dc79 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/SortType.java @@ -0,0 +1,6 @@ +package com.example.log4u.domain.diary; + +public enum SortType { + LATEST, + POPULAR +} diff --git a/src/main/java/com/example/log4u/domain/diary/VisibilityType.java b/src/main/java/com/example/log4u/domain/diary/VisibilityType.java new file mode 100644 index 00000000..f20883a5 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/VisibilityType.java @@ -0,0 +1,7 @@ +package com.example.log4u.domain.diary; + +public enum VisibilityType { + PUBLIC, + PRIVATE, + FOLLOWER +} diff --git a/src/main/java/com/example/log4u/domain/diary/WeatherInfo.java b/src/main/java/com/example/log4u/domain/diary/WeatherInfo.java new file mode 100644 index 00000000..7e0d75ed --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/WeatherInfo.java @@ -0,0 +1,8 @@ +package com.example.log4u.domain.diary; + +public enum WeatherInfo { + SUNNY, + RAINY, + CLOUDY, + SNOWY +} diff --git a/src/main/java/com/example/log4u/domain/diary/controller/DiaryController.java b/src/main/java/com/example/log4u/domain/diary/controller/DiaryController.java new file mode 100644 index 00000000..4dc03f4e --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/controller/DiaryController.java @@ -0,0 +1,94 @@ +package com.example.log4u.domain.diary.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.log4u.common.dto.PageResponse; +import com.example.log4u.domain.diary.SortType; +import com.example.log4u.domain.diary.dto.DiaryRequestDto; +import com.example.log4u.domain.diary.dto.DiaryResponseDto; +import com.example.log4u.domain.diary.service.DiaryService; +import com.example.log4u.domain.user.entity.SocialType; +import com.example.log4u.domain.user.entity.User; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/diaries") +@RequiredArgsConstructor +@Slf4j +public class DiaryController { + + private final DiaryService diaryService; + + @PostMapping + public ResponseEntity createDiary( + @Valid @RequestBody DiaryRequestDto request + ) { + User user = mockUser(); + diaryService.saveDiary(user.getUserId(), request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @GetMapping + public ResponseEntity> searchDiaries( + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "LATEST") SortType sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "6") int size + ) { + return ResponseEntity.ok( + diaryService.searchDiaries(keyword, sort, page, size) + ); + } + + @GetMapping("/{diaryId}") + public ResponseEntity getDiary( + @PathVariable Long diaryId + ) { + User user = mockUser(); + DiaryResponseDto diary = diaryService.getDiary(user.getUserId(), diaryId); + return ResponseEntity.ok(diary); + } + + @PatchMapping("/{diaryId}") + public ResponseEntity modifyDiary( + @PathVariable Long diaryId, + @Valid @RequestBody DiaryRequestDto request + ) { + User user = mockUser(); + diaryService.updateDiary(user.getUserId(), diaryId, request); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{diaryId}") + public ResponseEntity deleteDiary( + @PathVariable Long diaryId + ) { + User user = mockUser(); + diaryService.deleteDiary(user.getUserId(), diaryId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + private User mockUser() { + return User.builder() + .userId(1L) + .nickname("목유저") + .providerId("12345") + .socialType(SocialType.NAVER) + .email("mock@mock.com") + .statusMessage("목유저입니다.") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/log4u/domain/diary/dto/DiaryRequestDto.java b/src/main/java/com/example/log4u/domain/diary/dto/DiaryRequestDto.java new file mode 100644 index 00000000..51acb3f6 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/dto/DiaryRequestDto.java @@ -0,0 +1,36 @@ +package com.example.log4u.domain.diary.dto; + +import java.util.List; + +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.WeatherInfo; +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.media.dto.MediaRequestDto; + +import jakarta.validation.constraints.NotBlank; + +public record DiaryRequestDto( + @NotBlank(message = "제목은 필수입니다.") + String title, + @NotBlank(message = "내용은 필수입니다.") + String content, + Double latitude, + Double longitude, + WeatherInfo weatherInfo, + @NotBlank(message = "공개 범위는 필수입니다.") + VisibilityType visibility, + List mediaList +) { + public static Diary toEntity(Long userId, DiaryRequestDto diaryRequestDto, String thumbnailUrl) { + return Diary.builder() + .userId(userId) + .title(diaryRequestDto.title) + .content(diaryRequestDto.content) + .latitude(diaryRequestDto.latitude) + .longitude(diaryRequestDto.longitude) + .weatherInfo(diaryRequestDto.weatherInfo) + .visibility(diaryRequestDto.visibility) + .thumbnailUrl(thumbnailUrl) + .build(); + } +} diff --git a/src/main/java/com/example/log4u/domain/diary/dto/DiaryResponseDto.java b/src/main/java/com/example/log4u/domain/diary/dto/DiaryResponseDto.java new file mode 100644 index 00000000..4bdd98cb --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/dto/DiaryResponseDto.java @@ -0,0 +1,47 @@ +package com.example.log4u.domain.diary.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.media.dto.MediaResponseDto; +import com.example.log4u.domain.media.entity.Media; + +import lombok.Builder; + +@Builder +public record DiaryResponseDto( + Long diaryId, + Long userId, + Double latitude, + Double longitude, + String title, + String content, + String weatherInfo, + String visibility, + LocalDateTime createdAt, + LocalDateTime updatedAt, + String thumbnailUrl, + Long likeCount, + List mediaList + // TODO: isLiked 현재 로그인한 사용자의 좋아요 여부 +) { + public static DiaryResponseDto of(Diary diary, List media) { + return DiaryResponseDto.builder() + .diaryId(diary.getDiaryId()) + .userId(diary.getUserId()) + .latitude(diary.getLatitude()) + .longitude(diary.getLongitude()) + .title(diary.getTitle()) + .content(diary.getContent()) + .weatherInfo(diary.getWeatherInfo().name()) + .visibility(diary.getVisibility().name()) + .createdAt(diary.getCreatedAt()) + .updatedAt(diary.getUpdatedAt()) + .thumbnailUrl(diary.getThumbnailUrl()) + .likeCount(diary.getLikeCount()) + .mediaList(media.stream() + .map(MediaResponseDto::of).toList()) + .build(); + } +} diff --git a/src/main/java/com/example/log4u/domain/diary/entity/Diary.java b/src/main/java/com/example/log4u/domain/diary/entity/Diary.java index 9f558d7f..1f4c2bc7 100644 --- a/src/main/java/com/example/log4u/domain/diary/entity/Diary.java +++ b/src/main/java/com/example/log4u/domain/diary/entity/Diary.java @@ -1,9 +1,14 @@ package com.example.log4u.domain.diary.entity; import com.example.log4u.common.entity.BaseEntity; +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.WeatherInfo; +import com.example.log4u.domain.diary.dto.DiaryRequestDto; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -24,8 +29,6 @@ public class Diary extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long diaryId; - //JPA 연관관계 사용 X - // 외래키 방식을 사용 O @Column(nullable = false) private Long userId; @@ -37,14 +40,30 @@ public class Diary extends BaseEntity { @Column(nullable = false) private String content; - @Column(nullable = false) private Double latitude; - @Column(nullable = false) private Double longitude; + @Enumerated(EnumType.STRING) + private WeatherInfo weatherInfo; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private VisibilityType visibility; + @Column(nullable = false) - private Long likeCount; + @Builder.Default + private Long likeCount = 0L; + + public void update(DiaryRequestDto request, String newThumbnailUrl) { + this.title = request.title(); + this.content = request.content(); + this.latitude = request.latitude(); + this.longitude = request.longitude(); + this.weatherInfo = request.weatherInfo(); + this.visibility = request.visibility(); + this.thumbnailUrl = newThumbnailUrl; + } public Long incrementLikeCount() { this.likeCount++; @@ -55,4 +74,8 @@ public Long decreaseLikeCount() { this.likeCount--; return this.likeCount; } + + public boolean isOwner(Long userId) { + return this.userId.equals(userId); + } } diff --git a/src/main/java/com/example/log4u/domain/diary/exception/DiaryErrorCode.java b/src/main/java/com/example/log4u/domain/diary/exception/DiaryErrorCode.java index def651be..cdb9b9e6 100644 --- a/src/main/java/com/example/log4u/domain/diary/exception/DiaryErrorCode.java +++ b/src/main/java/com/example/log4u/domain/diary/exception/DiaryErrorCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum DiaryErrorCode implements ErrorCode { - NOT_FOUND_DIARY(HttpStatus.NOT_FOUND, "다이어리를 찾을 수 없습니다."); + NOT_FOUND_DIARY(HttpStatus.NOT_FOUND, "다이어리를 찾을 수 없습니다."), + OWNER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "작성자만 수정/삭제할 수 있습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/example/log4u/domain/diary/exception/OwnerAccessDeniedException.java b/src/main/java/com/example/log4u/domain/diary/exception/OwnerAccessDeniedException.java new file mode 100644 index 00000000..0976f34a --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/exception/OwnerAccessDeniedException.java @@ -0,0 +1,7 @@ +package com.example.log4u.domain.diary.exception; + +public class OwnerAccessDeniedException extends DiaryException { + public OwnerAccessDeniedException() { + super(DiaryErrorCode.OWNER_ACCESS_DENIED); + } +} diff --git a/src/main/java/com/example/log4u/domain/diary/repository/CustomDiaryRepository.java b/src/main/java/com/example/log4u/domain/diary/repository/CustomDiaryRepository.java new file mode 100644 index 00000000..343daef6 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/repository/CustomDiaryRepository.java @@ -0,0 +1,27 @@ +package com.example.log4u.domain.diary.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import com.example.log4u.domain.diary.SortType; +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.entity.Diary; + +public interface CustomDiaryRepository { + Page searchDiaries( + String keyword, + List visibilities, + SortType sort, + Pageable pageable + ); + + Slice findByUserIdAndVisibilityInAndCursorId( + Long userId, + List visibilities, + Long cursorId, + Pageable pageable + ); +} diff --git a/src/main/java/com/example/log4u/domain/diary/repository/CustomDiaryRepositoryImpl.java b/src/main/java/com/example/log4u/domain/diary/repository/CustomDiaryRepositoryImpl.java new file mode 100644 index 00000000..27af5de2 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/repository/CustomDiaryRepositoryImpl.java @@ -0,0 +1,135 @@ +package com.example.log4u.domain.diary.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.util.StringUtils; + +import com.example.log4u.domain.diary.SortType; +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.diary.entity.QDiary; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomDiaryRepositoryImpl implements CustomDiaryRepository { + private final JPAQueryFactory queryFactory; + + @Override + public Page searchDiaries( + String keyword, + List visibilities, + SortType sort, + Pageable pageable + ) { + QDiary diary = QDiary.diary; + + // 조건 생성 + BooleanExpression condition = createCondition(diary, keyword, visibilities, null); + + // 쿼리 실행 + JPAQuery query = queryFactory + .selectFrom(diary) + .where(condition); + + // 전체 카운트 조회 + Long total = queryFactory + .select(diary.count()) + .from(diary) + .where(condition) + .fetchOne(); + + // 데이터 조회 + List content = query + .orderBy(createOrderSpecifier(diary, sort)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + @Override + public Slice findByUserIdAndVisibilityInAndCursorId( + Long userId, + List visibilities, + Long cursorId, + Pageable pageable + ) { + QDiary diary = QDiary.diary; + + // 조건 생성 + BooleanExpression condition = createCondition(diary, null, visibilities, userId); + + if (cursorId != null) { + condition = condition.and(diary.diaryId.lt(cursorId)); // 커서 ID보다 작은 ID만 조회 + } + + // limit + 1로 다음 페이지 존재 여부 확인 + List content = queryFactory + .selectFrom(diary) + .where(condition) + .orderBy(diary.diaryId.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + // 다음 페이지 여부를 계산하여 반환 + return checkAndCreateSlice(content, pageable); + } + + // 하나의 메소드로 조건 생성 + private BooleanExpression createCondition( + QDiary diary, + String keyword, + List visibilities, + Long userId + ) { + BooleanExpression condition = diary.visibility.in(visibilities); + + // keyword가 있을 경우 + if (StringUtils.hasText(keyword)) { + condition = condition.and(diary.title.containsIgnoreCase(keyword) + .or(diary.content.containsIgnoreCase(keyword))); + } + + // userId가 있을 경우 + if (userId != null) { + condition = condition.and(diary.userId.eq(userId)); + } + + return condition; + } + + // 정렬 조건 생성 + private OrderSpecifier createOrderSpecifier(QDiary diary, SortType sort) { + if (sort == null) { + return diary.createdAt.desc(); + } + + return switch (sort) { + case POPULAR -> diary.likeCount.desc(); + case LATEST -> diary.createdAt.desc(); + }; + } + + // Slice 생성 및 hasNext 처리 + private Slice checkAndCreateSlice(List content, Pageable pageable) { + boolean hasNext = content.size() > pageable.getPageSize(); + + // 다음 페이지가 있으면 마지막 항목 제거 + if (hasNext) { + content.remove(content.size() - 1); // removeLast() 대신 인덱스로 처리 + } + + return new SliceImpl<>(content, pageable, hasNext); + } +} diff --git a/src/main/java/com/example/log4u/domain/diary/repository/DiaryRepository.java b/src/main/java/com/example/log4u/domain/diary/repository/DiaryRepository.java index fbd9d14b..5995d5ad 100644 --- a/src/main/java/com/example/log4u/domain/diary/repository/DiaryRepository.java +++ b/src/main/java/com/example/log4u/domain/diary/repository/DiaryRepository.java @@ -1,9 +1,10 @@ package com.example.log4u.domain.diary.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; import com.example.log4u.domain.diary.entity.Diary; -public interface DiaryRepository extends JpaRepository -{ -} +@Repository +public interface DiaryRepository extends JpaRepository, CustomDiaryRepository { +} \ No newline at end of file diff --git a/src/main/java/com/example/log4u/domain/diary/service/DiaryService.java b/src/main/java/com/example/log4u/domain/diary/service/DiaryService.java index e2f5d21a..8a7891a8 100644 --- a/src/main/java/com/example/log4u/domain/diary/service/DiaryService.java +++ b/src/main/java/com/example/log4u/domain/diary/service/DiaryService.java @@ -1,19 +1,196 @@ package com.example.log4u.domain.diary.service; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.example.log4u.common.dto.PageResponse; +import com.example.log4u.domain.diary.SortType; +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.dto.DiaryRequestDto; +import com.example.log4u.domain.diary.dto.DiaryResponseDto; import com.example.log4u.domain.diary.entity.Diary; import com.example.log4u.domain.diary.exception.NotFoundDiaryException; +import com.example.log4u.domain.diary.exception.OwnerAccessDeniedException; import com.example.log4u.domain.diary.repository.DiaryRepository; +import com.example.log4u.domain.follow.repository.FollowRepository; +import com.example.log4u.domain.media.entity.Media; +import com.example.log4u.domain.media.service.MediaService; +import com.example.log4u.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; - +import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor +@Slf4j public class DiaryService { private final DiaryRepository diaryRepository; + private final UserRepository userRepository; + private final FollowRepository followRepository; + private final MediaService mediaService; + + // 다이어리 생성 + @Transactional + public void saveDiary(Long userId, DiaryRequestDto request) { + String thumbnailUrl = mediaService.extractThumbnailUrl(request.mediaList()); + Diary diary = diaryRepository.save( + DiaryRequestDto.toEntity(userId, request, thumbnailUrl) + ); + mediaService.saveMedia(diary.getDiaryId(), request.mediaList()); + } + + // 다이어리 검색 + @Transactional(readOnly = true) + public PageResponse searchDiaries( + String keyword, + SortType sort, + int page, + int size + ) { + Page diaryPage = diaryRepository.searchDiaries( + keyword, + List.of(VisibilityType.PUBLIC), + sort, + PageRequest.of(page, size) + ); + + return PageResponse.of(mapToDtoPage(diaryPage)); + } + + // 다이어리 상세 조회 + @Transactional(readOnly = true) + public DiaryResponseDto getDiary(Long userId, Long diaryId) { + Diary diary = findDiaryOrThrow(diaryId); + + validateDiaryAccess(diary, userId); + + List media = mediaService.getMediaByDiaryId(diary.getDiaryId()); + return DiaryResponseDto.of(diary, media); + } + + // 다이어리 목록 (프로필 페이지) + @Transactional(readOnly = true) + public PageResponse getDiariesByCursor(Long userId, Long targetUserId, Long cursorId, int size) { + List visibilities = determineAccessibleVisibilities(userId, targetUserId); + + Slice diaries = diaryRepository.findByUserIdAndVisibilityInAndCursorId( + targetUserId, + visibilities, + cursorId != null ? cursorId : Long.MAX_VALUE, + PageRequest.of(0, size) + ); + + Slice dtoSlice = mapToDtoSlice(diaries); + + Long nextCursor = !dtoSlice.isEmpty() ? dtoSlice.getContent().getLast().diaryId() : null; + + return PageResponse.of(dtoSlice, nextCursor); + } + + // 다이어리 수정 + @Transactional + public void updateDiary(Long userId, Long diaryId, DiaryRequestDto request) { + Diary diary = findDiaryOrThrow(diaryId); + validateOwner(diary, userId); + + if (request.mediaList() != null) { + mediaService.updateMediaByDiaryId(diary.getDiaryId(), request.mediaList()); + } + + String newThumbnailUrl = mediaService.extractThumbnailUrl(request.mediaList()); + diary.update(request, newThumbnailUrl); + } + + // 다이어리 삭제 + @Transactional + public void deleteDiary(Long userId, Long diaryId) { + Diary diary = findDiaryOrThrow(diaryId); + validateOwner(diary, userId); + mediaService.deleteMediaByDiaryId(diaryId); + diaryRepository.delete(diary); + } + + private Diary findDiaryOrThrow(Long diaryId) { + return diaryRepository.findById(diaryId) + .orElseThrow(NotFoundDiaryException::new); + } + + // Page용 매핑 메서드 + private Page mapToDtoPage(Page page) { + List content = getDiaryResponsesWithMedia(page.getContent()); + return new PageImpl<>(content, page.getPageable(), page.getTotalElements()); + } + + // Slice용 매핑 메서드 + private Slice mapToDtoSlice(Slice slice) { + List content = getDiaryResponsesWithMedia(slice.getContent()); + return new SliceImpl<>(content, slice.getPageable(), slice.hasNext()); + } + + // 다이어리 + 미디어 같이 반환 + private List getDiaryResponsesWithMedia(List diaries) { + if (diaries.isEmpty()) { + return List.of(); + } + + List diaryIds = diaries.stream() + .map(Diary::getDiaryId) + .toList(); + + Map> mediaMap = mediaService.getMediaMapByDiaryIds(diaryIds); + + return diaries.stream() + .map(diary -> DiaryResponseDto.of( + diary, + mediaMap.getOrDefault(diary.getDiaryId(), List.of()) + )) + .toList(); + } + + // 다이어리 작성자 본인 체크 + private void validateOwner(Diary diary, Long userId) { + if (!diary.isOwner(userId)) { + throw new OwnerAccessDeniedException(); + } + } + + // 다이어리 목록 조회 시 권한 체크 + private List determineAccessibleVisibilities(Long userId, Long targetUserId) { + if (userId.equals(targetUserId)) { + return List.of(VisibilityType.PUBLIC, VisibilityType.PRIVATE, VisibilityType.FOLLOWER); + } + + if (followRepository.existsByFollowerIdAndFollowingId(userId, targetUserId)) { + return List.of(VisibilityType.PUBLIC, VisibilityType.FOLLOWER); + } + + return List.of(VisibilityType.PUBLIC); + } + + // 다이어리 상세 조회 시 권한 체크 + private void validateDiaryAccess(Diary diary, Long userId) { + if (diary.getVisibility() == VisibilityType.PRIVATE) { + if (!diary.getUserId().equals(userId)) { + throw new NotFoundDiaryException(); + } + } + + if (diary.getVisibility() == VisibilityType.FOLLOWER) { + if (!diary.getUserId().equals(userId) + && !followRepository.existsByFollowerIdAndFollowingId(userId, diary.getUserId())) { + throw new NotFoundDiaryException(); + } + } + } public Diary getDiary(Long diaryId) { return diaryRepository.findById(diaryId) @@ -40,5 +217,4 @@ public void checkDiaryExists(Long diaryId) { throw new NotFoundDiaryException(); } } - } diff --git a/src/main/java/com/example/log4u/domain/follow/entitiy/Follow.java b/src/main/java/com/example/log4u/domain/follow/entitiy/Follow.java new file mode 100644 index 00000000..8e712660 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/follow/entitiy/Follow.java @@ -0,0 +1,28 @@ +package com.example.log4u.domain.follow.entitiy; + +import com.example.log4u.common.entity.BaseEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Follow extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long followerId; + + private Long followingId; +} diff --git a/src/main/java/com/example/log4u/domain/follow/repository/FollowRepository.java b/src/main/java/com/example/log4u/domain/follow/repository/FollowRepository.java new file mode 100644 index 00000000..0f98ca20 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/follow/repository/FollowRepository.java @@ -0,0 +1,10 @@ +package com.example.log4u.domain.follow.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.log4u.domain.follow.entitiy.Follow; + +public interface FollowRepository extends JpaRepository { + + boolean existsByFollowerIdAndFollowingId(Long followerId, Long followingId); +} diff --git a/src/main/java/com/example/log4u/domain/like/entity/Like.java b/src/main/java/com/example/log4u/domain/like/entity/Like.java index 6b449d4e..46790b74 100644 --- a/src/main/java/com/example/log4u/domain/like/entity/Like.java +++ b/src/main/java/com/example/log4u/domain/like/entity/Like.java @@ -1,18 +1,22 @@ package com.example.log4u.domain.like.entity; import com.example.log4u.common.entity.BaseEntity; +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Getter diff --git a/src/main/java/com/example/log4u/domain/media/controller/MediaController.java b/src/main/java/com/example/log4u/domain/media/controller/MediaController.java new file mode 100644 index 00000000..9c43785e --- /dev/null +++ b/src/main/java/com/example/log4u/domain/media/controller/MediaController.java @@ -0,0 +1,58 @@ +package com.example.log4u.domain.media.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.example.log4u.domain.media.dto.MediaResponseDto; +import com.example.log4u.domain.media.service.MediaService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/media") +@RequiredArgsConstructor +@Slf4j +public class MediaController { + + private final MediaService mediaService; + + @PostMapping("/upload") + public ResponseEntity upload(@RequestParam("file") List files) { + List responses = List.of( + new MediaResponseDto(1L, "https://s3.amazonaws.com/example/image1.jpg", "jpeg"), + new MediaResponseDto(2L, "https://s3.amazonaws.com/example/image2.jpg", "jpeg") + ); + + return ResponseEntity.ok().body(responses); + } + + @DeleteMapping("/{mediaId}") + public ResponseEntity delete(@PathVariable("mediaId") String mediaId) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PutMapping + public ResponseEntity update( + @RequestParam("mediaIds") List mediaIds, + @RequestParam("files") List files + ) { + List responses = List.of( + new MediaResponseDto(1L, "https://s3.amazonaws.com/example/image1.jpg", "jpeg"), + new MediaResponseDto(2L, "https://s3.amazonaws.com/example/image2.jpg", "jpeg") + ); + + return ResponseEntity.ok().body(responses); + } + +} diff --git a/src/main/java/com/example/log4u/domain/media/dto/MediaRequestDto.java b/src/main/java/com/example/log4u/domain/media/dto/MediaRequestDto.java new file mode 100644 index 00000000..55fe7a9d --- /dev/null +++ b/src/main/java/com/example/log4u/domain/media/dto/MediaRequestDto.java @@ -0,0 +1,10 @@ +package com.example.log4u.domain.media.dto; + +public record MediaRequestDto( + String originalName, + String storedName, + String url, + String contentType, + Long size +) { +} diff --git a/src/main/java/com/example/log4u/domain/media/dto/MediaResponseDto.java b/src/main/java/com/example/log4u/domain/media/dto/MediaResponseDto.java new file mode 100644 index 00000000..819fb134 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/media/dto/MediaResponseDto.java @@ -0,0 +1,20 @@ +package com.example.log4u.domain.media.dto; + +import com.example.log4u.domain.media.entity.Media; + +import lombok.Builder; + +@Builder +public record MediaResponseDto( + Long mediaId, + String fileUrl, + String contentType +) { + public static MediaResponseDto of(Media media) { + return MediaResponseDto.builder() + .mediaId(media.getId()) + .fileUrl(media.getUrl()) + .contentType(media.getContentType()) + .build(); + } +} diff --git a/src/main/java/com/example/log4u/domain/media/entity/Media.java b/src/main/java/com/example/log4u/domain/media/entity/Media.java new file mode 100644 index 00000000..db1f7078 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/media/entity/Media.java @@ -0,0 +1,51 @@ +package com.example.log4u.domain.media.entity; + +import com.example.log4u.common.entity.BaseEntity; +import com.example.log4u.domain.media.dto.MediaRequestDto; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Media extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long diaryId; + + private String originalName; + + private String storedName; + + private String url; + + private String contentType; + + private Long size; + + public static Media toEntity(Long diaryId, MediaRequestDto request) { + return Media.builder() + .diaryId(diaryId) + .originalName(request.originalName()) + .storedName(request.storedName()) + .url(request.url()) + .contentType(request.contentType()) + .size(request.size()) + .build(); + } + +} diff --git a/src/main/java/com/example/log4u/domain/media/repository/MediaRepository.java b/src/main/java/com/example/log4u/domain/media/repository/MediaRepository.java new file mode 100644 index 00000000..91f09a7c --- /dev/null +++ b/src/main/java/com/example/log4u/domain/media/repository/MediaRepository.java @@ -0,0 +1,18 @@ +package com.example.log4u.domain.media.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; + +import com.example.log4u.domain.media.entity.Media; + +public interface MediaRepository extends JpaRepository { + + @Modifying + void deleteByDiaryId(Long diaryId); + + List findByDiaryId(Long diaryId); + + List findByDiaryIdIn(List diaryIds); +} diff --git a/src/main/java/com/example/log4u/domain/media/service/MediaService.java b/src/main/java/com/example/log4u/domain/media/service/MediaService.java new file mode 100644 index 00000000..cf0359c3 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/media/service/MediaService.java @@ -0,0 +1,72 @@ +package com.example.log4u.domain.media.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.log4u.domain.media.dto.MediaRequestDto; +import com.example.log4u.domain.media.entity.Media; +import com.example.log4u.domain.media.repository.MediaRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MediaService { + + private final MediaRepository mediaRepository; + + @Transactional + public void saveMedia(Long diaryId, List mediaList) { + if (mediaList == null || mediaList.isEmpty()) { + return; + } + List media = mediaList.stream() + .map(mediaDto -> Media.toEntity(diaryId, mediaDto)) + .toList(); + + mediaRepository.saveAll(media); + } + + @Transactional(readOnly = true) + public List getMediaByDiaryId(Long diaryId) { + return mediaRepository.findByDiaryId(diaryId); + } + + @Transactional + public void deleteMediaByDiaryId(Long diaryId) { + mediaRepository.deleteByDiaryId(diaryId); + } + + @Transactional + public void updateMediaByDiaryId(Long diaryId, List mediaList) { + deleteMediaByDiaryId(diaryId); + saveMedia(diaryId, mediaList); + // TODO: 기존꺼 다 삭제하는게 아닌 변경된 이미지만 반영되도록 수정해야함 + } + + public String extractThumbnailUrl(List mediaList) { + if (mediaList == null || mediaList.isEmpty()) { + return null; + } + return mediaList.getFirst().url(); + } + + public Map> getMediaMapByDiaryIds(List diaryIds) { + if (diaryIds.isEmpty()) { + return Map.of(); + } + try { + return mediaRepository.findByDiaryIdIn(diaryIds) + .stream() + .collect(Collectors.groupingBy(Media::getDiaryId)); + } catch (Exception e) { + return Map.of(); + } + } +} diff --git a/src/main/java/com/example/log4u/domain/reports/entity/Report.java b/src/main/java/com/example/log4u/domain/reports/entity/Report.java index 95178540..807c1f57 100644 --- a/src/main/java/com/example/log4u/domain/reports/entity/Report.java +++ b/src/main/java/com/example/log4u/domain/reports/entity/Report.java @@ -1,15 +1,11 @@ package com.example.log4u.domain.reports.entity; -import java.time.LocalDateTime; - -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - +import com.example.log4u.common.entity.BaseEntity; import com.example.log4u.domain.reports.reportTargetType.ReportTargetType; import com.example.log4u.domain.reports.reportType.ReportType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; @@ -27,24 +23,25 @@ @AllArgsConstructor(access = AccessLevel.PACKAGE) @Entity -@EntityListeners(AuditingEntityListener.class) -public class Report { +public class Report extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false) private Long reporterId; + @Column(nullable = false) @Enumerated(EnumType.STRING) private ReportTargetType reportTargetType; + @Column(nullable = false) @Enumerated(EnumType.STRING) private ReportType reportType; + @Column(nullable = false) private Long reportTargetId; + @Column(nullable = false) private String content; - - @CreatedDate - private LocalDateTime createdAt; } diff --git a/src/main/java/com/example/log4u/domain/supports/controller/SupportController.java b/src/main/java/com/example/log4u/domain/supports/controller/SupportController.java index 2f6e42e7..60077d51 100644 --- a/src/main/java/com/example/log4u/domain/supports/controller/SupportController.java +++ b/src/main/java/com/example/log4u/domain/supports/controller/SupportController.java @@ -37,7 +37,7 @@ public ResponseEntity createSupport( @GetMapping public ResponseEntity> getSupportOverviewPage( - @RequestParam(required = false) Integer page, + @RequestParam(defaultValue = "1") int page, @RequestParam(required = false) SupportType supportType ) { long requesterId = 1L; diff --git a/src/main/java/com/example/log4u/domain/supports/entity/Support.java b/src/main/java/com/example/log4u/domain/supports/entity/Support.java index 1d2aa355..7524229b 100644 --- a/src/main/java/com/example/log4u/domain/supports/entity/Support.java +++ b/src/main/java/com/example/log4u/domain/supports/entity/Support.java @@ -1,14 +1,13 @@ package com.example.log4u.domain.supports.entity; -import java.time.LocalDateTime; - -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - +import com.example.log4u.common.entity.BaseEntity; import com.example.log4u.domain.supports.supportType.SupportType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -25,25 +24,26 @@ @AllArgsConstructor(access = AccessLevel.PACKAGE) @Entity -@EntityListeners(AuditingEntityListener.class) -public class Support { +@AttributeOverride(name = "updatedAt", column = @Column(name = "ANSWERED_AT")) +public class Support extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private long requesterId; + @Column(nullable = false) + private Long requesterId; + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) private SupportType supportType; + @Column(nullable = false) private String title; + @Column(nullable = false) private String content; - @CreatedDate - private LocalDateTime createdAt; - @Setter + @Column(nullable = true) private String answerContent; - - private LocalDateTime answeredAt; } diff --git a/src/main/java/com/example/log4u/domain/supports/repository/SupportQuerydsl.java b/src/main/java/com/example/log4u/domain/supports/repository/SupportQuerydsl.java index 12e1afa2..12e1d8ac 100644 --- a/src/main/java/com/example/log4u/domain/supports/repository/SupportQuerydsl.java +++ b/src/main/java/com/example/log4u/domain/supports/repository/SupportQuerydsl.java @@ -43,7 +43,7 @@ public Page getSupportOverviewGetResponseDtoPage( support.supportType, support.title, support.createdAt, - support.answeredAt.isNotNull() // answered 필드는 answeredAt이 null 이 아니면 true + support.updatedAt.isNotNull() // answered 필드는 answeredAt이 null 이 아니면 true )) .where(builder) .orderBy(support.createdAt.desc()) @@ -67,7 +67,7 @@ public SupportGetResponseDto getSupportGetResponseDtoById( support.content, support.createdAt, support.answerContent, - support.answeredAt)) + support.updatedAt)) .where(support.id.eq(supportId) .and(support.requesterId.eq(requesterId))) .fetchOne(); diff --git a/src/main/java/com/example/log4u/domain/supports/service/SupportService.java b/src/main/java/com/example/log4u/domain/supports/service/SupportService.java index 9a2110bc..53bcc100 100644 --- a/src/main/java/com/example/log4u/domain/supports/service/SupportService.java +++ b/src/main/java/com/example/log4u/domain/supports/service/SupportService.java @@ -33,11 +33,10 @@ public void createSupport( @Transactional(readOnly = true) public Page getSupportPage( long requesterId, - Integer page, + int page, SupportType supportType ) { - int primitivePage = page == null ? 0 : page - 1; - Pageable pageable = PageRequest.of(primitivePage, 10); + Pageable pageable = PageRequest.of(page - 1, 10); return supportQuerydsl.getSupportOverviewGetResponseDtoPage(requesterId, pageable, supportType); } diff --git a/src/main/java/com/example/log4u/domain/user/controller/UserController.java b/src/main/java/com/example/log4u/domain/user/controller/UserController.java new file mode 100644 index 00000000..8d0c152e --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/controller/UserController.java @@ -0,0 +1,24 @@ +package com.example.log4u.domain.user.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.log4u.common.oauth2.dto.CustomOAuth2User; + +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/users") +@Slf4j +public class UserController { + + @GetMapping("") + public String modifyUserProfile( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + log.info("테스트 GET DATA user = " + customOAuth2User.getUserId()); + return "test"; + } +} diff --git a/src/main/java/com/example/log4u/domain/user/dto/UserCreateRequestDto.java b/src/main/java/com/example/log4u/domain/user/dto/UserCreateRequestDto.java new file mode 100644 index 00000000..fb3ee932 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/dto/UserCreateRequestDto.java @@ -0,0 +1,54 @@ +package com.example.log4u.domain.user.dto; + +import com.example.log4u.common.oauth2.dto.OAuth2Response; +import com.example.log4u.domain.user.entity.SocialType; +import com.example.log4u.domain.user.entity.User; + +public record UserCreateRequestDto( + SocialType socialType, + String providerId, + Long userId, + String name, + String email, + String nickname, + String profileImage, + String role +) { + public static User toEntity(UserCreateRequestDto userCreateRequestDto) { + return User.builder() + .socialType(userCreateRequestDto.socialType) + .providerId(userCreateRequestDto.providerId) + .name(userCreateRequestDto.name) + .email(userCreateRequestDto.email) + .nickname(userCreateRequestDto.nickname) + .profileImage(userCreateRequestDto.profileImage) + .role(userCreateRequestDto.role) + .build(); + } + + public static UserCreateRequestDto fromOAuth2Response(OAuth2Response oAuth2Response, Long userId, String role) { + return new UserCreateRequestDto( + oAuth2Response.getSocialType(), + oAuth2Response.getProviderId(), + userId, + oAuth2Response.getName(), + oAuth2Response.getEmail(), + oAuth2Response.getNickname(), + oAuth2Response.getProfileImage(), + role + ); + } + + public static UserCreateRequestDto fromEntity(User user) { + return new UserCreateRequestDto( + user.getSocialType(), + user.getProviderId(), + user.getUserId(), + user.getName(), + user.getEmail(), + user.getNickname(), + user.getProfileImage(), + user.getRole() + ); + } +} diff --git a/src/main/java/com/example/log4u/domain/user/entity/User.java b/src/main/java/com/example/log4u/domain/user/entity/User.java index b20e0d9d..e1e3fc66 100644 --- a/src/main/java/com/example/log4u/domain/user/entity/User.java +++ b/src/main/java/com/example/log4u/domain/user/entity/User.java @@ -1,6 +1,7 @@ package com.example.log4u.domain.user.entity; import com.example.log4u.common.entity.BaseEntity; +import com.example.log4u.common.oauth2.dto.OAuth2Response; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -9,6 +10,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -20,20 +22,28 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder +@Table(name = "users") public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long userId; + @Column(nullable = false) + private String name; + @Column(nullable = false) private String nickname; + private String email; + @Column(nullable = false) - private Long providerId; + private String providerId; + + private String profileImage; @Column(nullable = false) - private String email; + private String role; @Enumerated(EnumType.STRING) @Column(nullable = false) @@ -42,5 +52,12 @@ public class User extends BaseEntity { private String statusMessage; @Column(nullable = false) - private boolean isPremium; + private boolean isPremium = false; + + public void updateOauth2Profile(OAuth2Response oAuth2Response) { + this.email = oAuth2Response.getEmail(); + this.name = oAuth2Response.getName(); + this.nickname = oAuth2Response.getNickname(); + this.profileImage = oAuth2Response.getProfileImage(); + } } diff --git a/src/main/java/com/example/log4u/domain/user/exception/UserErrorCode.java b/src/main/java/com/example/log4u/domain/user/exception/UserErrorCode.java new file mode 100644 index 00000000..dfe718ec --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/exception/UserErrorCode.java @@ -0,0 +1,28 @@ +package com.example.log4u.domain.user.exception; + +import org.springframework.http.HttpStatus; + +import com.example.log4u.common.exception.base.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "유저 정보가 존재하지 않습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getErrorMessage() { + return message; + } +} + diff --git a/src/main/java/com/example/log4u/domain/user/exception/UserNotFoundException.java b/src/main/java/com/example/log4u/domain/user/exception/UserNotFoundException.java new file mode 100644 index 00000000..eda91ee6 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/exception/UserNotFoundException.java @@ -0,0 +1,9 @@ +package com.example.log4u.domain.user.exception; + +import com.example.log4u.common.exception.base.ServiceException; + +public class UserNotFoundException extends ServiceException { + public UserNotFoundException() { + super(UserErrorCode.USER_NOT_FOUND); + } +} diff --git a/src/main/java/com/example/log4u/domain/user/repository/UserRepository.java b/src/main/java/com/example/log4u/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..c917d39e --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.example.log4u.domain.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.log4u.domain.user.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByNickname(String nickname); + Optional findByProviderId(String providerId); +} diff --git a/src/main/java/com/example/log4u/domain/user/service/UserService.java b/src/main/java/com/example/log4u/domain/user/service/UserService.java new file mode 100644 index 00000000..5b4f5fbf --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/service/UserService.java @@ -0,0 +1,21 @@ +package com.example.log4u.domain.user.service; + +import org.springframework.stereotype.Service; + +import com.example.log4u.domain.user.entity.User; +import com.example.log4u.domain.user.exception.UserNotFoundException; +import com.example.log4u.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public User getUserById(Long userId) { + return userRepository.findById(userId).orElseThrow( + UserNotFoundException::new + ); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..657b79d4 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,66 @@ +logging: + level: + org: + springframework: debug + +spring: + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: create + + datasource: + url: jdbc:mysql://localhost:3307/log4u + username: dev + password: devcos4-team08 + driver-class-name: com.mysql.cj.jdbc.Driver + + security: + oauth2: + client: + registration: + naver: + client-name: naver + client-id: ${naver.dev.client-id} + client-secret: ${naver.dev.client-secret} + redirect-uri: ${naver.dev.redirect-uri} + authorization-grant-type: authorization_code + scope: + - name + - email + - nickname + + google: + client-name: google + client-id: ${google.dev.client-id} + client-secret: ${google.dev.client-secret} + redirect-uri: ${google.dev.redirect-uri} + authorization-grant-type: authorization_code + scope: + - profile + - email + + kakao: + client-id: ${kakao.dev.client-id} + client-secret: ${kakao.dev.client-secret} + redirect-uri: ${kakao.dev.redirect-uri} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - profile_nickname + - profile_image + - account_email + client-name: kakao + + provider: + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..53b08229 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,66 @@ +logging: + level: + org: + springframework: debug + +spring: + datasource: + url: ${aws-mysql.url} + username: ${aws-mysql.username} + password: ${aws-mysql.password} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: validate + + security: + oauth2: + client: + registration: + naver: + client-name: naver + client-id: ${naver.prod.client-id} + client-secret: ${naver.prod.client-secret} + redirect-uri: ${naver.prod.redirect-uri} + authorization-grant-type: authorization_code + scope: + - name + - email + - nickname + + google: + client-name: google + client-id: ${google.prod.client-id} + client-secret: ${google.prod.client-secret} + redirect-uri: ${google.prod.redirect-uri} + authorization-grant-type: authorization_code + scope: + - profile + - email + + kakao: + client-id: ${kakao.prod.client-id} + client-secret: ${kakao.prod.client-secret} + redirect-uri: ${kakao.prod.redirect-uri} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - profile_nickname + - profile_image + - account_email + client-name: kakao + + provider: + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 00000000..1701600c --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29b..a27c8dab 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,7 @@ +spring: + profiles: + group: + prod: "prod, prod-secret" + dev: "dev, dev-secret" + # 프로필 변경 시 사용 + active: dev \ No newline at end of file diff --git a/src/test/java/com/example/log4u/Log4UApplicationTests.java b/src/test/java/com/example/log4u/Log4UApplicationTests.java index 246b70f4..dbff6bb5 100644 --- a/src/test/java/com/example/log4u/Log4UApplicationTests.java +++ b/src/test/java/com/example/log4u/Log4UApplicationTests.java @@ -1,13 +1,12 @@ package com.example.log4u; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class Log4UApplicationTests { - @Test - void contextLoads() { - } + // @Test + // void contextLoads() { + // } } diff --git a/src/test/java/com/example/log4u/domain/comment/service/CommonServiceTest.java b/src/test/java/com/example/log4u/domain/comment/service/CommonServiceTest.java index 8e11d1a8..8878ec53 100644 --- a/src/test/java/com/example/log4u/domain/comment/service/CommonServiceTest.java +++ b/src/test/java/com/example/log4u/domain/comment/service/CommonServiceTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -12,8 +13,14 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import com.example.log4u.common.dto.PageResponse; import com.example.log4u.domain.comment.dto.request.CommentCreateRequestDto; +import com.example.log4u.domain.comment.dto.response.CommentResponseDto; import com.example.log4u.domain.comment.entity.Comment; import com.example.log4u.domain.comment.exception.NotFoundCommentException; import com.example.log4u.domain.comment.exception.UnauthorizedAccessException; @@ -134,6 +141,34 @@ void commentDelete_Fail_Unauthorized() { verify(commentRepository, never()).delete(any()); } + @DisplayName("성공 테스트: 특정 다이어리 댓글 전체 조회 (커서 기반)") + @Test + void getCommentsList_In_DiaryDetail_Success() { + // given + Long diaryId = 1L; + int size = 5; + Long cursorCommentId = null; + + List commentList = CommentFixture.createCommentsListFixture(size + 1); // hasNext 판별 위해 +1 + Pageable pageable = PageRequest.of(0, size); + boolean hasNext = commentList.size() > size; + + List sliced = hasNext ? commentList.subList(0, size) : commentList; + + Slice slice = new SliceImpl<>(sliced, pageable, hasNext); + given(commentRepository.findByDiaryIdWithCursor(diaryId, cursorCommentId, pageable)) + .willReturn(slice); + + // when + PageResponse response = commentService.getCommentListByDiary(diaryId, cursorCommentId, size); + + // then + assertThat(response.content()).hasSize(sliced.size()); + assertThat(response.pageInfo().hasNext()).isEqualTo(hasNext); + assertThat(response.pageInfo().nextCursor()).isEqualTo(hasNext ? sliced.getLast().getCommentId() : null); + + verify(commentRepository).findByDiaryIdWithCursor(diaryId, cursorCommentId, pageable); + } } diff --git a/src/test/java/com/example/log4u/domain/diary/controller/DiaryControllerTest.java b/src/test/java/com/example/log4u/domain/diary/controller/DiaryControllerTest.java new file mode 100644 index 00000000..b60ffe26 --- /dev/null +++ b/src/test/java/com/example/log4u/domain/diary/controller/DiaryControllerTest.java @@ -0,0 +1,33 @@ +package com.example.log4u.domain.diary.controller; + +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.example.log4u.domain.diary.repository.DiaryRepository; +import com.example.log4u.domain.media.repository.MediaRepository; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +public class DiaryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DiaryRepository diaryRepository; + + @Autowired + private MediaRepository mediaRepository; + + // TODO: 시큐리티 구현 완료 후 통합 테스트 +} diff --git a/src/test/java/com/example/log4u/domain/diary/repository/DiaryRepositoryTest.java b/src/test/java/com/example/log4u/domain/diary/repository/DiaryRepositoryTest.java new file mode 100644 index 00000000..0a7af482 --- /dev/null +++ b/src/test/java/com/example/log4u/domain/diary/repository/DiaryRepositoryTest.java @@ -0,0 +1,205 @@ +package com.example.log4u.domain.diary.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +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.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; + +import com.example.log4u.common.config.QueryDslConfig; +import com.example.log4u.domain.diary.SortType; +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.fixture.DiaryFixture; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QueryDslConfig.class) +public class DiaryRepositoryTest { + + @Autowired + private DiaryRepository diaryRepository; + + @PersistenceContext + private EntityManager em; + + private final Long userId1 = 1L; + private final Long userId2 = 2L; + + @BeforeEach + void setUp() { + diaryRepository.deleteAll(); + em.createNativeQuery("ALTER TABLE diary ALTER COLUMN diary_id RESTART WITH 1").executeUpdate(); + List diaries = DiaryFixture.createDiariesFixture(); + diaryRepository.saveAll(diaries); + } + + @Test + @DisplayName("키워드로 공개 다이어리 검색") + void searchDiariesByKeyword() { + // given + String keyword = "날씨"; + List visibilities = List.of(VisibilityType.PUBLIC); + PageRequest pageable = PageRequest.of(0, 10); + + // when + Page result = diaryRepository.searchDiaries(keyword, visibilities, SortType.LATEST, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("첫번째 일기"); + assertThat(result.getContent().get(0).getContent()).contains("날씨"); + } + + @Test + @DisplayName("인기순으로 다이어리 정렬") + void searchDiariesSortByPopular() { + // given + List visibilities = List.of(VisibilityType.PUBLIC); + PageRequest pageable = PageRequest.of(0, 10); + + // when + Page result = diaryRepository.searchDiaries(null, visibilities, SortType.POPULAR, pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).getLikeCount()).isGreaterThanOrEqualTo( + result.getContent().get(1).getLikeCount()); + assertThat(result.getContent().get(1).getLikeCount()).isGreaterThanOrEqualTo( + result.getContent().get(2).getLikeCount()); + } + + @Test + @DisplayName("최신순으로 다이어리 정렬") + void searchDiariesSortByLatest() { + // given + List visibilities = List.of(VisibilityType.PUBLIC); + PageRequest pageable = PageRequest.of(0, 10); + + // when + Page result = diaryRepository.searchDiaries(null, visibilities, SortType.LATEST, pageable); + + // then + assertThat(result.getContent()).hasSize(3); + + // 실제로는 createdAt 필드를 비교해야 하지만 테스트에선 데이터 생성 순서로 대체 + if (result.getContent().size() >= 2) { + assertThat(result.getContent().get(0).getCreatedAt()) + .isAfterOrEqualTo(result.getContent().get(1).getCreatedAt()); + } + } + + @Test + @DisplayName("사용자 ID와 공개 범위로 다이어리 조회") + void findByUserIdAndVisibilityIn() { + // given + List visibilities = List.of(VisibilityType.PUBLIC, VisibilityType.PRIVATE, + VisibilityType.FOLLOWER); + PageRequest pageable = PageRequest.of(0, 10); + + // when + Slice result = diaryRepository.findByUserIdAndVisibilityInAndCursorId( + userId1, visibilities, Long.MAX_VALUE, pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().stream().allMatch(d -> d.getUserId().equals(userId1))).isTrue(); + } + + @Test + @DisplayName("팔로워 범위로만 다이어리 조회") + void findByVisibilityTypeFollower() { + // given + List visibilities = List.of(VisibilityType.FOLLOWER); + PageRequest pageable = PageRequest.of(0, 10); + + // when + Slice result = diaryRepository.findByUserIdAndVisibilityInAndCursorId( + userId1, visibilities, Long.MAX_VALUE, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getVisibility()).isEqualTo(VisibilityType.FOLLOWER); + } + + @Test + @DisplayName("커서 기반 페이징으로 다이어리 조회") + void findByUserIdAndVisibilityInWithCursor() { + // given + List visibilities = List.of(VisibilityType.PUBLIC); + PageRequest pageable = PageRequest.of(0, 1); + Long cursorId = 5L; // 인기 있는 일기의 ID + + // when + Slice result = diaryRepository.findByUserIdAndVisibilityInAndCursorId( + null, visibilities, cursorId, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getDiaryId()).isLessThan(cursorId); + + System.out.println(result.getContent().get(0).getDiaryId()); + } + + @Test + @DisplayName("빈 키워드로 검색시 모든 공개 다이어리 반환") + void searchDiariesWithEmptyKeyword() { + // given + String keyword = ""; + List visibilities = List.of(VisibilityType.PUBLIC); + PageRequest pageable = PageRequest.of(0, 10); + + // when + Page result = diaryRepository.searchDiaries(keyword, visibilities, SortType.LATEST, pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().stream() + .allMatch(d -> d.getVisibility() == VisibilityType.PUBLIC)).isTrue(); + } + + @Test + @DisplayName("페이지 크기보다 작은 결과 조회시 hasNext는 false") + void sliceHasNextIsFalseWhenResultSizeIsLessThanPageSize() { + // given + List visibilities = List.of(VisibilityType.PUBLIC); + PageRequest pageable = PageRequest.of(0, 5); // 페이지 크기가 5 + + // when + Slice result = diaryRepository.findByUserIdAndVisibilityInAndCursorId( + userId1, visibilities, Long.MAX_VALUE, pageable); + + // then + assertThat(result.getContent().size()).isLessThan(pageable.getPageSize()); + assertThat(result.hasNext()).isFalse(); + } + + @Test + @DisplayName("페이지 크기와 같은 결과 조회시 hasNext 확인") + void checkHasNextWhenResultSizeEqualsPageSize() { + // given + List visibilities = List.of(VisibilityType.PUBLIC, VisibilityType.PRIVATE, + VisibilityType.FOLLOWER); + PageRequest pageable = PageRequest.of(0, 3); // 페이지 크기가 3, 결과도 3개 + + // when + Slice result = diaryRepository.findByUserIdAndVisibilityInAndCursorId( + userId1, visibilities, Long.MAX_VALUE, pageable); + + // then + assertThat(result.getContent().size()).isEqualTo(pageable.getPageSize()); + assertThat(result.hasNext()).isFalse(); + } +} diff --git a/src/test/java/com/example/log4u/domain/diary/service/DiaryServiceTest.java b/src/test/java/com/example/log4u/domain/diary/service/DiaryServiceTest.java new file mode 100644 index 00000000..ce83dfdb --- /dev/null +++ b/src/test/java/com/example/log4u/domain/diary/service/DiaryServiceTest.java @@ -0,0 +1,428 @@ +package com.example.log4u.domain.diary.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import com.example.log4u.common.dto.PageResponse; +import com.example.log4u.domain.diary.SortType; +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.dto.DiaryRequestDto; +import com.example.log4u.domain.diary.dto.DiaryResponseDto; +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.diary.exception.NotFoundDiaryException; +import com.example.log4u.domain.diary.exception.OwnerAccessDeniedException; +import com.example.log4u.domain.diary.repository.DiaryRepository; +import com.example.log4u.domain.follow.repository.FollowRepository; +import com.example.log4u.domain.media.entity.Media; +import com.example.log4u.domain.media.service.MediaService; +import com.example.log4u.domain.user.repository.UserRepository; +import com.example.log4u.fixture.DiaryFixture; +import com.example.log4u.fixture.MediaFixture; + +@ExtendWith(MockitoExtension.class) +public class DiaryServiceTest { + + @Mock + private DiaryRepository diaryRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private FollowRepository followRepository; + + @Mock + private MediaService mediaService; + + @InjectMocks + private DiaryService diaryService; + + private static final int CURSOR_PAGE_SIZE = 12; + + private static final int SEARCH_PAGE_SIZE = 6; + + @Test + @DisplayName("다이어리 생성 성공") + void saveDiary() { + // given + Long userId = 1L; + DiaryRequestDto request = DiaryFixture.createDiaryRequestDtoFixture(); + + String thumbnailUrl = "https://example.com/image1.jpg"; + Diary diary = DiaryFixture.createPublicDiaryFixture(1L, userId); + + given(mediaService.extractThumbnailUrl(request.mediaList())).willReturn(thumbnailUrl); + given(diaryRepository.save(any(Diary.class))).willReturn(diary); + + // when + diaryService.saveDiary(userId, request); + + // then + verify(mediaService).saveMedia(eq(diary.getDiaryId()), eq(request.mediaList())); + } + + @Test + @DisplayName("키워드로 다이어리 검색 성공") + void searchDiaries() { + // given + String keyword = "테스트"; + SortType sort = SortType.LATEST; + int page = 0; + int size = 6; + + List diaries = DiaryFixture.createDiariesWithIdsFixture(3); + Page diaryPage = new PageImpl<>(diaries, PageRequest.of(0, SEARCH_PAGE_SIZE), 3); + + given(diaryRepository.searchDiaries( + eq(keyword), + eq(List.of(VisibilityType.PUBLIC)), + eq(sort), + any(PageRequest.class) + )).willReturn(diaryPage); + + Map> mediaMap = new HashMap<>(); + for (Diary diary : diaries) { + mediaMap.put(diary.getDiaryId(), List.of( + MediaFixture.createMediaFixture(diary.getDiaryId() * 10, diary.getDiaryId()) + )); + } + + given(mediaService.getMediaMapByDiaryIds(anyList())).willReturn(mediaMap); + + // when + PageResponse result = diaryService.searchDiaries(keyword, sort, page, size); + + // then + assertThat(result.content()).hasSize(3); + assertThat(result.pageInfo().totalPages()).isEqualTo(1); + assertThat(result.pageInfo().totalElements()).isEqualTo(3); + + assertThat(result.content()).allSatisfy(diary -> { + assertThat(diary.title().contains(keyword) || diary.content().contains(keyword)) + .as("다이어리 제목 또는 내용에 키워드 '%s'가 포함되어야 합니다.", keyword) + .isTrue(); + }); + + DiaryResponseDto firstDiary = result.content().get(0); + assertThat(firstDiary.diaryId()).isEqualTo(diaries.get(0).getDiaryId()); + assertThat(firstDiary.title()).isEqualTo(diaries.get(0).getTitle()); + assertThat(firstDiary.content()).isEqualTo(diaries.get(0).getContent()); + assertThat(firstDiary.userId()).isEqualTo(diaries.get(0).getUserId()); + assertThat(firstDiary.visibility()).isEqualTo(diaries.get(0).getVisibility().name()); + assertThat(firstDiary.weatherInfo()).isEqualTo(diaries.get(0).getWeatherInfo().name()); + assertThat(firstDiary.mediaList()).hasSize(1); + } + + @Test + @DisplayName("로그인한 사용자가 공개 다이어리 상세 조회 성공") + void getDiaryDetail_public() { + // given + Long diaryId = 1L; + Long userId = 1L; + + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, 2L); // 다른 사용자의 공개 다이어리 + List mediaList = List.of(MediaFixture.createMediaFixture(10L, diaryId)); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + given(mediaService.getMediaByDiaryId(diaryId)).willReturn(mediaList); + + // when + DiaryResponseDto result = diaryService.getDiary(userId, diaryId); + + // then + assertThat(result.diaryId()).isEqualTo(diaryId); + assertThat(result.userId()).isEqualTo(2L); + assertThat(result.visibility()).isEqualTo(VisibilityType.PUBLIC.name()); + } + + @Test + @DisplayName("로그인한 사용자가 팔로워 다이어리 상세 조회 성공 - 팔로워인 경우") + void getDiaryDetail_follower_success() { + // given + Long diaryId = 1L; + Long userId = 1L; + Long authorId = 2L; + + Diary diary = DiaryFixture.createFollowerDiaryFixture(diaryId, authorId); // 다른 사용자의 팔로워 다이어리 + List mediaList = List.of(MediaFixture.createMediaFixture(10L, diaryId)); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + given(followRepository.existsByFollowerIdAndFollowingId(userId, authorId)).willReturn(true); + given(mediaService.getMediaByDiaryId(diaryId)).willReturn(mediaList); + + // when + DiaryResponseDto result = diaryService.getDiary(userId, diaryId); + + // then + assertThat(result.diaryId()).isEqualTo(diaryId); + assertThat(result.userId()).isEqualTo(authorId); + assertThat(result.visibility()).isEqualTo(VisibilityType.FOLLOWER.name()); + } + + @Test + @DisplayName("로그인한 사용자가 팔로워 다이어리 상세 조회 실패 - 팔로워가 아닌 경우") + void getDiaryDetail_follower_fail() { + // given + Long diaryId = 1L; + Long userId = 1L; + Long authorId = 2L; + + Diary diary = DiaryFixture.createFollowerDiaryFixture(diaryId, authorId); // 다른 사용자의 팔로워 다이어리 + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + given(followRepository.existsByFollowerIdAndFollowingId(userId, authorId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> diaryService.getDiary(userId, diaryId)) + .isInstanceOf(NotFoundDiaryException.class); + } + + @Test + @DisplayName("로그인한 사용자가 비공개 다이어리 상세 조회 성공 - 작성자인 경우") + void getDiaryDetail_private_success() { + // given + Long diaryId = 1L; + Long userId = 1L; + + Diary diary = DiaryFixture.createPrivateDiaryFixture(diaryId, userId); // 자신의 비공개 다이어리 + List mediaList = List.of(MediaFixture.createMediaFixture(10L, diaryId)); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + given(mediaService.getMediaByDiaryId(diaryId)).willReturn(mediaList); + + // when + DiaryResponseDto result = diaryService.getDiary(userId, diaryId); + + // then + assertThat(result.diaryId()).isEqualTo(diaryId); + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.visibility()).isEqualTo(VisibilityType.PRIVATE.name()); + } + + @Test + @DisplayName("로그인한 사용자가 비공개 다이어리 상세 조회 실패 - 작성자가 아닌 경우") + void getDiaryDetail_private_fail() { + // given + Long diaryId = 1L; + Long userId = 1L; + Long authorId = 2L; + + Diary diary = DiaryFixture.createPrivateDiaryFixture(diaryId, authorId); // 다른 사용자의 비공개 다이어리 + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + + // when & then + assertThatThrownBy(() -> diaryService.getDiary(userId, diaryId)) + .isInstanceOf(NotFoundDiaryException.class); + } + + @Test + @DisplayName("커서 기반 다이어리 목록 조회 성공") + void getDiariesByCursor() { + // given + Long userId = 1L; + Long targetUserId = 2L; + Long cursorId = 5L; + int size = 12; + + List diaries = DiaryFixture.createDiariesWithIdsFixture(3); + Slice diarySlice = new SliceImpl<>(diaries, PageRequest.of(0, CURSOR_PAGE_SIZE), false); + + given(diaryRepository.findByUserIdAndVisibilityInAndCursorId( + eq(targetUserId), + eq(List.of(VisibilityType.PUBLIC)), + eq(cursorId), + any(PageRequest.class) + )).willReturn(diarySlice); + + Map> mediaMap = new HashMap<>(); + for (Diary diary : diaries) { + mediaMap.put(diary.getDiaryId(), List.of( + MediaFixture.createMediaFixture(diary.getDiaryId() * 10, diary.getDiaryId()) + )); + } + + given(mediaService.getMediaMapByDiaryIds(anyList())).willReturn(mediaMap); + + // when + PageResponse result = diaryService.getDiariesByCursor(userId, targetUserId, cursorId, size); + + // then + assertThat(result.content()).hasSize(3); + assertThat(result.pageInfo().hasNext()).isFalse(); + } + + @Test + @DisplayName("다이어리 수정 성공") + void updateDiary() { + // given + Long userId = 1L; + Long diaryId = 1L; + DiaryRequestDto request = DiaryFixture.createPublicDiaryRequestDtoFixture(); + + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, userId); + String newThumbnailUrl = "https://example.com/public.jpg"; + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + given(mediaService.extractThumbnailUrl(request.mediaList())).willReturn(newThumbnailUrl); + + // when + diaryService.updateDiary(userId, diaryId, request); + + // then + verify(mediaService).updateMediaByDiaryId(eq(diaryId), eq(request.mediaList())); + assertThat(diary.getTitle()).isEqualTo(request.title()); + assertThat(diary.getContent()).isEqualTo(request.content()); + assertThat(diary.getThumbnailUrl()).isEqualTo(newThumbnailUrl); + } + + @Test + @DisplayName("다이어리 수정 실패 - 작성자가 아닌 경우") + void updateDiary_notOwner() { + // given + Long userId = 1L; + Long authorId = 2L; + Long diaryId = 1L; + DiaryRequestDto request = DiaryFixture.createPublicDiaryRequestDtoFixture(); + + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, authorId); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + + // when & then + assertThatThrownBy(() -> diaryService.updateDiary(userId, diaryId, request)) + .isInstanceOf(OwnerAccessDeniedException.class); + } + + @Test + @DisplayName("다이어리 삭제 성공") + void deleteDiary() { + // given + Long userId = 1L; + Long diaryId = 1L; + + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, userId); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + + // when + diaryService.deleteDiary(userId, diaryId); + + // then + verify(mediaService).deleteMediaByDiaryId(diaryId); + verify(diaryRepository).delete(diary); + } + + @Test + @DisplayName("다이어리 삭제 실패 - 작성자가 아닌 경우") + void deleteDiary_notOwner() { + // given + Long userId = 1L; + Long authorId = 2L; + Long diaryId = 1L; + + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, authorId); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + + // when & then + assertThatThrownBy(() -> diaryService.deleteDiary(userId, diaryId)) + .isInstanceOf(OwnerAccessDeniedException.class); + } + + @Test + @DisplayName("좋아요 수 증가 성공") + void incrementLikeCount() { + // given + Long diaryId = 1L; + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, 1L); + Long initialLikeCount = diary.getLikeCount(); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + + // when + Long newLikeCount = diaryService.incrementLikeCount(diaryId); + + // then + assertThat(newLikeCount).isEqualTo(initialLikeCount + 1); + assertThat(diary.getLikeCount()).isEqualTo(initialLikeCount + 1); + } + + @Test + @DisplayName("좋아요 수 감소 성공") + void decreaseLikeCount() { + // given + Long diaryId = 1L; + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, 1L); + Long initialLikeCount = diary.getLikeCount(); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + + // when + Long newLikeCount = diaryService.decreaseLikeCount(diaryId); + + // then + assertThat(newLikeCount).isEqualTo(initialLikeCount - 1); + assertThat(diary.getLikeCount()).isEqualTo(initialLikeCount - 1); + } + + @Test + @DisplayName("좋아요 수 조회 성공") + void getLikeCount() { + // given + Long diaryId = 1L; + Diary diary = DiaryFixture.createPublicDiaryFixture(diaryId, 1L); + + given(diaryRepository.findById(diaryId)).willReturn(Optional.of(diary)); + + // when + Long likeCount = diaryService.getLikeCount(diaryId); + + // then + assertThat(likeCount).isEqualTo(diary.getLikeCount()); + } + + @Test + @DisplayName("다이어리 존재 여부 확인 성공") + void checkDiaryExists() { + // given + Long diaryId = 1L; + + given(diaryRepository.existsById(diaryId)).willReturn(true); + + // when & then + assertThatCode(() -> diaryService.checkDiaryExists(diaryId)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("다이어리 존재 여부 확인 실패") + void checkDiaryExists_notFound() { + // given + Long diaryId = 1L; + + given(diaryRepository.existsById(diaryId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> diaryService.checkDiaryExists(diaryId)) + .isInstanceOf(NotFoundDiaryException.class); + } +} diff --git a/src/test/java/com/example/log4u/domain/support/service/SupportServiceTest.java b/src/test/java/com/example/log4u/domain/support/service/SupportServiceTest.java new file mode 100644 index 00000000..976c7029 --- /dev/null +++ b/src/test/java/com/example/log4u/domain/support/service/SupportServiceTest.java @@ -0,0 +1,86 @@ +package com.example.log4u.domain.support.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; + +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.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import com.example.log4u.domain.supports.dto.SupportCreateRequestDto; +import com.example.log4u.domain.supports.dto.SupportGetResponseDto; +import com.example.log4u.domain.supports.dto.SupportOverviewGetResponseDto; +import com.example.log4u.domain.supports.entity.Support; +import com.example.log4u.domain.supports.repository.SupportQuerydsl; +import com.example.log4u.domain.supports.repository.SupportRepository; +import com.example.log4u.domain.supports.service.SupportService; +import com.example.log4u.domain.supports.supportType.SupportType; + +@DisplayName("문의 API 단위 테스트") +@ExtendWith(MockitoExtension.class) +public class SupportServiceTest { + @InjectMocks + private SupportService supportService; + + @Mock + private SupportRepository supportRepository; + + @Mock + private SupportQuerydsl supportQuerydsl; + + @DisplayName("성공 테스트 : 문의 등록") + @Test + void testCreateSupport() { + long requesterId = 1L; + SupportCreateRequestDto supportCreateRequestDto = mock(SupportCreateRequestDto.class); + Support supportEntity = mock(Support.class); + + given(supportCreateRequestDto.toEntity(requesterId)).willReturn(supportEntity); + + supportService.createSupport(requesterId, supportCreateRequestDto); + + verify(supportRepository).save(supportEntity); + } + + @DisplayName("성공 테스트 : 문의 페이지 조회") + @Test + void testGetSupportPage() { + long requesterId = 1L; + SupportType supportType = SupportType.ETC; + PageRequest pageable = PageRequest.of(0, 10); + SupportOverviewGetResponseDto supportOverview = mock(SupportOverviewGetResponseDto.class); + Page supportPage = new PageImpl<>(List.of(supportOverview)); + + given(supportQuerydsl.getSupportOverviewGetResponseDtoPage(requesterId, pageable, supportType)) + .willReturn(supportPage); + + Page result = supportService.getSupportPage(requesterId, 1, supportType); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + } + + @DisplayName("성공 테스트 : 특정 문의 상세 조회") + @Test + void testGetSupportById() { + long requesterId = 1L; + Long supportId = 1L; + SupportGetResponseDto supportGetResponseDto = mock(SupportGetResponseDto.class); + + given(supportQuerydsl.getSupportGetResponseDtoById(requesterId, supportId)) + .willReturn(supportGetResponseDto); + + SupportGetResponseDto result = supportService.getSupportById(requesterId, supportId); + + assertNotNull(result); + verify(supportQuerydsl).getSupportGetResponseDtoById(requesterId, supportId); + } +} diff --git a/src/test/java/com/example/log4u/fixture/CommentFixture.java b/src/test/java/com/example/log4u/fixture/CommentFixture.java index 314bbf8d..329ba8ce 100644 --- a/src/test/java/com/example/log4u/fixture/CommentFixture.java +++ b/src/test/java/com/example/log4u/fixture/CommentFixture.java @@ -1,5 +1,8 @@ package com.example.log4u.fixture; +import java.util.ArrayList; +import java.util.List; + import com.example.log4u.domain.comment.entity.Comment; public class CommentFixture { @@ -16,4 +19,19 @@ public static Comment createCommentFixture(Long commentId, Long userId, Long dia public static Comment createDefaultComment() { return createCommentFixture(1L, 1L, 1L); } + + public static List createCommentsListFixture(int count) { + List comments = new ArrayList<>(); + Long diaryId = 1L; + + for (int i = 1; i <= count; i++) { + comments.add(Comment.builder() + .commentId((long) i) + .userId((long) i) + .diaryId(diaryId) + .content("댓글" + i) + .build()); + } + return comments; + } } diff --git a/src/test/java/com/example/log4u/fixture/DiaryFixture.java b/src/test/java/com/example/log4u/fixture/DiaryFixture.java index c53dbbe7..b8553751 100644 --- a/src/test/java/com/example/log4u/fixture/DiaryFixture.java +++ b/src/test/java/com/example/log4u/fixture/DiaryFixture.java @@ -1,6 +1,13 @@ package com.example.log4u.fixture; +import java.util.ArrayList; +import java.util.List; + +import com.example.log4u.domain.diary.VisibilityType; +import com.example.log4u.domain.diary.WeatherInfo; +import com.example.log4u.domain.diary.dto.DiaryRequestDto; import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.media.dto.MediaRequestDto; public class DiaryFixture { @@ -16,4 +23,173 @@ public static Diary createDiaryFixture() { .likeCount(11L) .build(); } + + public static Diary createCustomDiaryFixture( + Long diaryId, + Long userId, + String title, + String content, + String thumbnailUrl, + VisibilityType visibility, + Double latitude, + Double longitude, + WeatherInfo weatherInfo, + Long likeCount + ) { + return Diary.builder() + .diaryId(diaryId) + .userId(userId) + .title(title) + .content(content) + .thumbnailUrl(thumbnailUrl) + .visibility(visibility) + .latitude(latitude) + .longitude(longitude) + .weatherInfo(weatherInfo) + .likeCount(likeCount) + .build(); + } + + public static List createDiariesFixture() { + List diaries = new ArrayList<>(); + + diaries.add(createCustomDiaryFixture( + null, 1L, "첫번째 일기", "오늘은 날씨가 좋았다", "https://example.com/thumb1.jpg", + VisibilityType.PUBLIC, 37.5665, 126.9780, WeatherInfo.SUNNY, 5L + )); + + diaries.add(createCustomDiaryFixture( + null, 1L, "두번째 일기", "비밀 내용입니다", "https://example.com/thumb2.jpg", + VisibilityType.PRIVATE, 37.5665, 126.9780, WeatherInfo.CLOUDY, 0L + )); + + diaries.add(createCustomDiaryFixture( + null, 1L, "세번째 일기", "팔로워만 볼 수 있는 내용", "https://example.com/thumb3.jpg", + VisibilityType.FOLLOWER, 37.5665, 126.9780, WeatherInfo.RAINY, 3L + )); + + diaries.add(createCustomDiaryFixture( + null, 2L, "다른 사용자의 일기", "공개 내용", "https://example.com/thumb4.jpg", + VisibilityType.PUBLIC, 35.1796, 129.0756, WeatherInfo.SUNNY, 10L + )); + + diaries.add(createCustomDiaryFixture( + null, 2L, "인기 있는 일기", "좋아요가 많은 내용", "https://example.com/thumb5.jpg", + VisibilityType.PUBLIC, 35.1796, 129.0756, WeatherInfo.SNOWY, 20L + )); + + return diaries; + } + + public static List createDiariesWithIdsFixture(int count) { + List diaries = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + diaries.add(createCustomDiaryFixture( + (long)i, 1L, "제목 테스트 " + i, "내용 테스트" + i, "https://example.com/thumb" + i + ".jpg", + VisibilityType.PUBLIC, 37.5665, 126.9780, WeatherInfo.SUNNY, (long)i + )); + } + return diaries; + } + + public static List createUserDiariesFixture(Long userId, int count) { + List diaries = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + diaries.add(createCustomDiaryFixture( + (long)i, userId, "사용자 " + userId + "의 일기 " + i, "내용 " + i, + "https://example.com/user" + userId + "/thumb" + i + ".jpg", + VisibilityType.PUBLIC, 37.5665, 126.9780, WeatherInfo.SUNNY, (long)i + )); + } + return diaries; + } + + public static Diary createPublicDiaryFixture(Long diaryId, Long userId) { + return createCustomDiaryFixture( + diaryId, userId, "공개 일기", "누구나 볼 수 있는 내용", "https://example.com/public.jpg", + VisibilityType.PUBLIC, 37.5665, 126.9780, WeatherInfo.SUNNY, 5L + ); + } + + public static Diary createPrivateDiaryFixture(Long diaryId, Long userId) { + return createCustomDiaryFixture( + diaryId, userId, "비공개 일기", "나만 볼 수 있는 내용", "https://example.com/private.jpg", + VisibilityType.PRIVATE, 37.5665, 126.9780, WeatherInfo.CLOUDY, 0L + ); + } + + public static Diary createFollowerDiaryFixture(Long diaryId, Long userId) { + return createCustomDiaryFixture( + diaryId, userId, "팔로워 일기", "팔로워만 볼 수 있는 내용", "https://example.com/follower.jpg", + VisibilityType.FOLLOWER, 37.5665, 126.9780, WeatherInfo.RAINY, 3L + ); + } + + public static DiaryRequestDto createDiaryRequestDtoFixture() { + List mediaList = List.of( + new MediaRequestDto("image1.jpg", "stored1.jpg", "https://example.com/image1.jpg", "image/jpeg", 1000L), + new MediaRequestDto("image2.jpg", "stored2.jpg", "https://example.com/image2.jpg", "image/jpeg", 2000L) + ); + + return new DiaryRequestDto( + "테스트 제목", + "테스트 내용", + 37.5665, + 126.9780, + WeatherInfo.SUNNY, + VisibilityType.PUBLIC, + mediaList + ); + } + + public static DiaryRequestDto createPublicDiaryRequestDtoFixture() { + List mediaList = List.of( + new MediaRequestDto("public.jpg", "public_stored.jpg", "https://example.com/public.jpg", "image/jpeg", + 1000L) + ); + + return new DiaryRequestDto( + "공개 테스트 제목", + "공개 테스트 내용", + 37.5665, + 126.9780, + WeatherInfo.SUNNY, + VisibilityType.PUBLIC, + mediaList + ); + } + + public static DiaryRequestDto createPrivateDiaryRequestDtoFixture() { + List mediaList = List.of( + new MediaRequestDto("private.jpg", "private_stored.jpg", "https://example.com/private.jpg", "image/jpeg", + 1000L) + ); + + return new DiaryRequestDto( + "비공개 테스트 제목", + "비공개 테스트 내용", + 37.5665, + 126.9780, + WeatherInfo.CLOUDY, + VisibilityType.PRIVATE, + mediaList + ); + } + + public static DiaryRequestDto createFollowerDiaryRequestDtoFixture() { + List mediaList = List.of( + new MediaRequestDto("follower.jpg", "follower_stored.jpg", "https://example.com/follower.jpg", "image/jpeg", + 1000L) + ); + + return new DiaryRequestDto( + "팔로워 테스트 제목", + "팔로워 테스트 내용", + 37.5665, + 126.9780, + WeatherInfo.RAINY, + VisibilityType.FOLLOWER, + mediaList + ); + } } diff --git a/src/test/java/com/example/log4u/fixture/MediaFixture.java b/src/test/java/com/example/log4u/fixture/MediaFixture.java new file mode 100644 index 00000000..569833af --- /dev/null +++ b/src/test/java/com/example/log4u/fixture/MediaFixture.java @@ -0,0 +1,18 @@ +package com.example.log4u.fixture; + +import com.example.log4u.domain.media.entity.Media; + +public class MediaFixture { + + public static Media createMediaFixture(Long mediaId, Long diaryId) { + return Media.builder() + .id(mediaId) + .diaryId(diaryId) + .originalName("image.jpg") + .storedName("stored.jpg") + .url("url.jpg") + .contentType("image/jpeg") + .size(1000L) + .build(); + } +} diff --git a/src/test/java/com/example/log4u/fixture/UserFixture.java b/src/test/java/com/example/log4u/fixture/UserFixture.java index db87a26f..f6bdec48 100644 --- a/src/test/java/com/example/log4u/fixture/UserFixture.java +++ b/src/test/java/com/example/log4u/fixture/UserFixture.java @@ -9,7 +9,7 @@ public static User createUserFixture() { return User.builder() .userId(1L) .nickname("testUser") - .providerId(123L) + .providerId("123") .email("test@example.com") .socialType(SocialType.KAKAO) .statusMessage("상태 메시지") @@ -21,7 +21,7 @@ public static User createUserFixture(Long userId) { return User.builder() .userId(userId) .nickname("testUser" + userId) - .providerId(100L + userId) + .providerId("100 + userId") .email("test" + userId + "@example.com") .socialType(SocialType.KAKAO) .statusMessage("상태 메시지 " + userId) @@ -33,7 +33,7 @@ public static User createPremiumUserFixture(Long userId) { return User.builder() .userId(userId) .nickname("premiumUser" + userId) - .providerId(1000L + userId) + .providerId("1000L + userId") .email("premium" + userId + "@example.com") .socialType(SocialType.KAKAO) .statusMessage("프리미엄 사용자")