diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md index 32566caa..319d64a8 100644 --- a/.github/ISSUE_TEMPLATE/issue_template.md +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -1,7 +1,7 @@ --- -name: 이슈 템플릿 -about: "\b해당 이슈 생성 템플릿을 사용하여 이슈 생성" -title: '' +name: "✅ Issue Template" +about: 기본 Issue Template +title: 예시) ✨ Feature 이슈 제목 labels: '' assignees: '' diff --git a/.github/workflows/mody-dev.yml b/.github/workflows/mody-dev.yml new file mode 100644 index 00000000..ccb818d4 --- /dev/null +++ b/.github/workflows/mody-dev.yml @@ -0,0 +1,92 @@ +name: MODY service Dev Build and Deploy to AWS + +on: + push: + branches: + - develop + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + # 1. 코드 체크아웃 + - name: Checkout Code + uses: actions/checkout@v3 + + # 1-1. Java 21 세팅 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + # 1-2. application.yml 파일 생성 + - name: make application.yml + run: | + # create application.yml + cd ./src/main + cd ./resources + + # application.yml 파일 생성하기 + touch ./application.yml + + # Secrets에 저장한 값을 application.yml 파일에 쓰기 + echo "${{ secrets.DEV_YML }}" >> ./application.yml + shell: bash # 스크립트가 Bash 셸에서 실행 + + # 1-2-1 start.sh 파일 생성 + - name: make start.sh + run: | + # create start.sh + cd ./scripts + + # start.sh 파일 생성하기 + touch ./start.sh + + # Secrets에 저장한 값을 start.sh 파일에 쓰기 + echo "${{ secrets.START_SH }}" >> ./start.sh + + # 1-3. Spring Boot 애플리케이션 빌드 + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew clean build -x test + + # 2. AWS CLI 설정 + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + # 3. Docker 로그인 + - name: Log in to Amazon ECR + run: | + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REPO }} + + # 4. Docker 이미지 빌드 + - name: Build Docker Image + run: | + docker build -t ${{ secrets.AWS_ECR_REPO }}:latest . + docker tag ${{ secrets.AWS_ECR_REPO }}:latest ${{ secrets.AWS_ECR_REPO }}:latest + + # 5. Docker 이미지 푸시 + - name: Push to Amazon ECR + run: | + docker push ${{ secrets.AWS_ECR_REPO }}:latest + + # 6-0. S3 업로드 + - name: Upload to S3 + run: | + zip -r deploy.zip appspec.yml scripts/ + aws s3 cp deploy.zip s3://modi-service-bucket/deploy/deploy.zip + + # 6. CodeDeploy 트리거 + - name: Trigger CodeDeploy Deployment + run: | + aws deploy create-deployment \ + --application-name modi-server \ + --deployment-group-name modi-server-group \ + --revision "{\"revisionType\":\"S3\",\"s3Location\":{\"bucket\":\"modi-service-bucket\",\"key\":\"deploy/deploy.zip\",\"bundleType\":\"zip\"}}" diff --git a/.github/workflows/mody-prod.yml b/.github/workflows/mody-prod.yml new file mode 100644 index 00000000..8802ecad --- /dev/null +++ b/.github/workflows/mody-prod.yml @@ -0,0 +1,74 @@ +name: MODY service Build and Deploy to AWS + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + # 1. 코드 체크아웃 + - name: Checkout Code + uses: actions/checkout@v3 + + # 1-1. Java 21 세팅 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + # 1-2. application.yml 파일 생성 + - name: make application.yml + run: | + # create application.yml + cd ./src/main + cd ./resources + + # application.yml 파일 생성하기 + touch ./application.yml + + # Secrets에 저장한 값을 application.yml 파일에 쓰기 + echo "${{ secrets.YML }}" >> ./application.yml + shell: bash # 스크립트가 Bash 셸에서 실행 + + # 1-3. Spring Boot 애플리케이션 빌드 + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew clean build -x test + + # 2. AWS CLI 설정 + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + # 3. Docker 로그인 + - name: Log in to Amazon ECR + run: | # AWS ECR에 로그인 (AWS CLI 사용) + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REPO }} + + # 4. Docker 이미지 빌드 + - name: Build Docker Image + run: | + docker build -t ${{ secrets.AWS_ECR_REPO }}:prod . + docker tag ${{ secrets.AWS_ECR_REPO }}:prod ${{ secrets.AWS_ECR_REPO }}:prod + + # 5. Docker 이미지 푸시 + - name: Push to Amazon ECR + run: | + docker push ${{ secrets.AWS_ECR_REPO }}:prod + + # 6. CodeDeploy 트리거 + - name: Trigger CodeDeploy Deployment + run: | + aws deploy create-deployment \ + --application-name my-app \ + --deployment-group-name my-app-group \ + --revision "{\"revisionType\":\"AppSpecContent\",\"appSpecContent\":{\"content\":\"$(cat appspec.yml)\"}}" diff --git a/.gitignore b/.gitignore index 022c0b3e..2481f47d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ HELP.md .gradle build/ +!build.gradle !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ @@ -36,4 +37,10 @@ out/ ### VS Code ### .vscode/ -/application.yml \ No newline at end of file +application.yml + +## QClass ## +!**/src/main/generated/ + +## log +logs/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6c355c30 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM openjdk:21-jdk-slim + +# 크롬 설치 +RUN apt-get update && apt-get install -y wget curl unzip \ + && wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ + && apt-get install -y ./google-chrome-stable_current_amd64.deb \ + && rm ./google-chrome-stable_current_amd64.deb \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# JAR 파일 복사 및 실행 +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} mody-server-0.0.1.jar +ENTRYPOINT ["java","-jar","/mody-server-0.0.1.jar"] \ No newline at end of file diff --git a/README.md b/README.md index c1d45314..f6c73204 100644 --- a/README.md +++ b/README.md @@ -1 +1,141 @@ -# mody-server \ No newline at end of file +# mody + +> "모드(Mode)"와 "버디(Buddy)"의 결합으로 만들어진 이 이름은, 당신의 **라이프스타일과 개성을 반영하는 최적의 '모드'를 발견하도록 돕는 친구**를 뜻합니다. +> 고객의 체형, 취향, 라이프스타일 데이터를 기반으로 맞춤형 스타일링 경험을 제공합니다. +> 단순한 추천을 넘어, 고객 스스로 스타일링 능력을 기를 수 있도록 돕는 서비스입니다. + +
+ +![image](https://github.com/user-attachments/assets/3863789b-de5a-45e5-8a63-a72f58d02cdf) +
+ +## 프로젝트 기능 +1. 종합 패션 스타일 컨설팅 + * 체형 분석 + * 스타일 추천 + * 패션 아이템 추천 + * 브랜드 추천 +2. 데일리 패션 추천 + * 날씨 기반 추천 + * 일정 및 라이프스타일 반영 추천 +3. 부가 기능 + * 스타일링 팁 제공 + * 쇼핑 연계 기능 + * 저장 및 비교 기능 + * 스타일 커뮤니티 +4. 기능 기대 효과 + * 시간 절약: 고객이 스타일 고민에 소모하던 시간을 획기적으로 단축 + * 개성 표현: 획일적인 스타일에서 벗어나, 개인의 개성과 매력을 돋보이게 함 + * 편리한 쇼핑: 추천과 쇼핑을 한 번에 해결하여 구매 실패율을 낮춤 + * 지속적인 성장: 고객 스스로 스타일링 실력을 키워 패션 근육을 강화 + +## Member + +| 박동규 | 김재헌 | 서상효 | 김성욱 | 최윤서 | +|:---------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------:| +| image | image | image | image | image | +| [dong99u](https://github.com/dong99u) | [jher235](https://github.com/jher235) | [seoshinehyo](https://github.com/seoshinehyo) | [so3659](https://github.com/so3659) | [yunseo02](https://github.com/yunseo02) | + +## Tech Stack +
+ +| 기술 스택 | 버전 | +|:---------------------------------------------------------------------------------------------------------------------:|:------:| +| | 21 | +| ![Spring Boot](https://img.shields.io/badge/Spring%20Boot-6DB33F?style=for-the-badge&logo=springboot&logoColor=white) | 3.3.5 | +| ![MySQL](https://img.shields.io/badge/MySQL-4479A1?style=for-the-badge&logo=mysql&logoColor=white) | 8.0.39 | + + + + + + + + +
+ +## 협업 규칙 + +### Github 협업 규칙 + +Github 협업 규칙은 아래와 같습니다. + +1. 전체적인 협업 flow는 Github flow를 따름. +2. Fork한 저장소를 각자 local로 가져와 수정. +3. 수정한 코드는 add -> commit -> push 후, upstream에 Pull Request를 수행. +4. main branch로부터 dev branch, prod branch를 구성. +5. 추가되는 기능에 대해서는 feature branch를 생성하여 각 기능별 branch를 구성. +6. Pull Request 시 Code Review 이후 Merge 진행. +7. Commit 규칙은 아래와 같이 진행했습니다. + + | 커밋 타입 | 설명 | + | --------- | -------------------------------------------------------------- | + | feature | 새로운 기능 구현 | + | fix | 수정 | + | refactor | 리팩토링 | + | docs | 문서 수정 | + | style | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 | + | chore | 패키지 구조 수정, code와 무관한 부분들 (.gitignore, build.gradle 같은) 수정 | + | hotfix | hotfix | + | remove | 패키지나 클래스 삭제 | + | test | 테스트 코드, 리펙토링 테스트 코드 추가 | + | rename | 패키지나 클래스명 수정 | + | comment | 주석 추가 | + +### Issue 활용 +![image](https://github.com/user-attachments/assets/3c16e50a-9c5c-4457-8f0d-13b0f9e73788) + +- Github 레포지토리의 Issue탭에 Todo인 상황 혹은 In progress에 대한 상황을 작성하고 공유했습니다. 해당 Issue 번호로 각자의 로컬 레포지토리에 브랜치를 생성하여 Pull Request 시에 해당 Issue를 언급하여 공유했습니다. 해당 전략을 사용하여 Merge Conflict의 발생 가능성을 줄였습니다. + +### PR 활용 +![image](https://github.com/user-attachments/assets/02656e24-d5d9-4e5b-a16b-925fd674cc53) + +- 다음과 같이 개발 이후 특정 프로젝트에 대한 변경사항을 제안하고, 팀원과 이를 검토 및 논의한 후, 최종적으로 해당 변경사항을 반영할 수 있도록 했습니다. +- 다른 개발자들은 해당 Pull Request를 검토하고, 필요한 경우 피드백을 제공할 수 있었습니다. +- 검토 후, Pull Request가 승인되면 변경 사항이 메인 프로젝트로 병합되도록 했습니다. 반면, 추가적인 수정이 필요한 경우 개발자는 피드백을 반영하여 수정하고, 수정된 변경사항을 다시 push 했습니다. + +## 구현결과 + +
+ +

로그인 / 회원가입

+ +![image](https://github.com/user-attachments/assets/867c85a4-761c-4ef6-842c-23b4d066fce7) +![image](https://github.com/user-attachments/assets/b1d49b5a-6e70-4a71-98b1-16f041456048) +![image](https://github.com/user-attachments/assets/2aee9521-a4ed-4e05-b8f4-5f2e5c437f09) +![image](https://github.com/user-attachments/assets/3b477bcb-c034-4107-b66a-eea815e6a21b) +![image](https://github.com/user-attachments/assets/cb493564-0c7b-4f1b-bc95-8c39e280b2a7) + + +

체형 분석

+ +![image](https://github.com/user-attachments/assets/6c206542-aafc-4856-a67e-e041c97a6d84) +![image](https://github.com/user-attachments/assets/e4c5b166-cbc3-4bf9-b148-91c62c2422b8) + +

스타일 추천

+ +![image](https://github.com/user-attachments/assets/0e743011-7340-43a0-b272-7f9f280ea888) +

패션 아이템 추천

+ +![image](https://github.com/user-attachments/assets/7043c532-cf06-4031-9391-63b359b2790f) + +

마이페이지

+Image +Image +Image +Image + +

비슷한 체형 게시글

+Image +Image + + +
+ +## Contact + +* 박동규 : qkrehdrb0813@gmail.com +* 김재헌 : king09044@naver.com +* 서상효 : springssh0504@naver.com +* 김성욱 : so3659@naver.com +* 최윤서 : chldbstj021902@gmail.com diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 00000000..a3ae9fd0 --- /dev/null +++ b/appspec.yml @@ -0,0 +1,14 @@ +version: 0.0 +os: linux +files: + - source: / + destination: /home/ec2-user/app # 애플리케이션 파일들이 위치한 경로 +hooks: + BeforeInstall: + - location: scripts/stop.sh + timeout: 300 # 5분 + runas: root + AfterInstall: + - location: scripts/start.sh + timeout: 300 + runas: root diff --git a/build.gradle b/build.gradle index d0357109..e13e021a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,68 +1,98 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.5' - id 'io.spring.dependency-management' version '1.1.6' - id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'java' + id 'org.springframework.boot' version '3.3.5' + id 'io.spring.dependency-management' version '1.1.6' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } group = 'com.example' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } +ext { + snippetsDir = file('build/generated-snippets') +} + + dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + //sentry 사용 + implementation 'io.sentry:sentry-spring-boot-starter-jakarta:7.9.0' + + //jwt 사용 0.12.3 버전 사용 + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + + // 스웨거 사용 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.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" + + // AWS 서비스 의존성 추가 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + //Webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // macos 전용 netty server + implementation 'io.netty:netty-resolver-dns-native-macos' - //jwt 사용 - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // 테스트 + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + runtimeOnly 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' + // Oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - // 스웨거 사용 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + // P6Spy + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.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" + // Mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - // AWS 서비스 의존성 추가 - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' - //Webflux - implementation 'org.springframework.boot:spring-boot-starter-webflux' - // macos 전용 netty server - implementation 'io.netty:netty-resolver-dns-native-macos' + // Selenium + implementation 'org.seleniumhq.selenium:selenium-java:4.8.0' - // 테스트 - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - outputs.dir snippetsDir - useJUnitPlatform() + outputs.dir snippetsDir + useJUnitPlatform() } diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 00000000..471e0050 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,52 @@ +services: # 컨테이너 설정 + backend: + container_name: mody-server-dev + image: mody-server:dev #ecr에 올린 이미지로 수정 필요 + environment: + - SPRING_PROFILES_ACTIVE=dev + - REDIS_HOST=redis + - REDIS_PORT=6379 + ports: + - 8000:8000 + depends_on: + - redis + networks: + - mody-network + + nginx: # Nginx 컨테이너 설정 (certbot은 인증서 발급 시에만 포트를 사용하게 설정) + image: nginx + container_name: nginx + restart: unless-stopped # 컨테이너가 비정상적으로 종료되었을 때 재시작 + ports: + - "880:80" + - "8443:443" + volumes: + - ./certbot/conf:/etc/letsencrypt # 파일 설정을 nginx에 마운트 시킴 + - ./certbot/www:/var/www/certbot + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/conf/portainer.mody.server.kkoalla.app.conf:/etc/nginx/conf.d/portainer.mody.server.kkoalla.app.conf + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + networks: + - mody-network + + certbot: + image: certbot/certbot + container_name: certbot + volumes: + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; certbot certonly --manual --preferred-challenges dns --email shimseawon1510@gmail.com -d kkoalla.app -d *.kkoalla.app --agree-tos --no-eff-email; exit;'" + + redis: + image: redis + container_name: redis + restart: unless-stopped # 컨테이너가 비정상적으로 종료되었을 때 재시작 + ports: + - "6379:6379" + networks: + - mody-network + command: redis-server --appendonly yes # redis 서버가 비정상적으로 종료되었을 때 데이터를 보존하기 위해 설정 + +networks: + mody-network: + driver: bridge diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 00000000..91eacf6c --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,28 @@ +services: # 컨테이너 설정 + backend-prod: + container_name: mody-server-prod + image: mody-server #ecr에 올린 이미지로 수정 필요 #ecr에 올린 이미지로 수정 필요 + environment: + - SPRING_PROFILES_ACTIVE=prod + - REDIS_HOST=redis + - REDIS_PORT=6379 + ports: + - 8080:8080 + depends_on: + - redis-prod + networks: + - mody-prod-network + + redis-prod: + image: redis + container_name: redis-prod + restart: unless-stopped # 컨테이너가 비정상적으로 종료되었을 때 재시작 + ports: + - "66379:66379" + networks: + - mody-prod-network + command: redis-server --appendonly yes # redis 서버가 비정상적으로 종료되었을 때 데이터를 보존하기 위해 설정 + +networks: + mody-prod-network: + driver: bridge \ No newline at end of file diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100644 index 00000000..ef5ba822 --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# 컨테이너 중지 +docker-compose -f /home/ec2-user/app/docker-compose-dev.yml stop backend +docker-compose -f /home/ec2-user/app/docker-compose-dev.yml rm -f backend + + +# 안 사용하는 이미지 정리 +docker image prune -f \ No newline at end of file diff --git a/src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java b/src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java new file mode 100644 index 00000000..205d68ae --- /dev/null +++ b/src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java @@ -0,0 +1,64 @@ +package com.example.mody.domain.auth.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QRefreshToken is a Querydsl query type for RefreshToken + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QRefreshToken extends EntityPathBase { + + private static final long serialVersionUID = 1361191399L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QRefreshToken refreshToken = new QRefreshToken("refreshToken"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.mody.domain.member.entity.QMember member; + + public final StringPath token = createString("token"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QRefreshToken(String variable) { + this(RefreshToken.class, forVariable(variable), INITS); + } + + public QRefreshToken(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QRefreshToken(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QRefreshToken(PathMetadata metadata, PathInits inits) { + this(RefreshToken.class, metadata, inits); + } + + public QRefreshToken(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/QAnswer.java b/src/main/generated/com/example/mody/domain/bodytype/entity/QAnswer.java new file mode 100644 index 00000000..d61c3bc5 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/bodytype/entity/QAnswer.java @@ -0,0 +1,66 @@ +package com.example.mody.domain.bodytype.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QAnswer is a Querydsl query type for Answer + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QAnswer extends EntityPathBase { + + private static final long serialVersionUID = -978464325L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QAnswer answer = new QAnswer("answer"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + public final StringPath content = createString("content"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath imageUrl = createString("imageUrl"); + + public final QQuestion question; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QAnswer(String variable) { + this(Answer.class, forVariable(variable), INITS); + } + + public QAnswer(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QAnswer(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QAnswer(PathMetadata metadata, PathInits inits) { + this(Answer.class, metadata, inits); + } + + public QAnswer(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.question = inits.isInitialized("question") ? new QQuestion(forProperty("question")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/QBodyType.java b/src/main/generated/com/example/mody/domain/bodytype/entity/QBodyType.java new file mode 100644 index 00000000..8567cda9 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/bodytype/entity/QBodyType.java @@ -0,0 +1,53 @@ +package com.example.mody.domain.bodytype.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QBodyType is a Querydsl query type for BodyType + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QBodyType extends EntityPathBase { + + private static final long serialVersionUID = -1799118151L; + + public static final QBodyType bodyType = new QBodyType("bodyType"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final ListPath memberBodyTypeList = this.createList("memberBodyTypeList", com.example.mody.domain.bodytype.entity.mapping.MemberBodyType.class, com.example.mody.domain.bodytype.entity.mapping.QMemberBodyType.class, PathInits.DIRECT2); + + public final StringPath name = createString("name"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QBodyType(String variable) { + super(BodyType.class, forVariable(variable)); + } + + public QBodyType(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBodyType(PathMetadata metadata) { + super(BodyType.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/QQuestion.java b/src/main/generated/com/example/mody/domain/bodytype/entity/QQuestion.java new file mode 100644 index 00000000..4dd9ffda --- /dev/null +++ b/src/main/generated/com/example/mody/domain/bodytype/entity/QQuestion.java @@ -0,0 +1,53 @@ +package com.example.mody.domain.bodytype.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QQuestion is a Querydsl query type for Question + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QQuestion extends EntityPathBase { + + private static final long serialVersionUID = -372686173L; + + public static final QQuestion question = new QQuestion("question"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + public final ListPath answerList = this.createList("answerList", Answer.class, QAnswer.class, PathInits.DIRECT2); + + public final StringPath content = createString("content"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QQuestion(String variable) { + super(Question.class, forVariable(variable)); + } + + public QQuestion(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QQuestion(PathMetadata metadata) { + super(Question.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberAnswer.java b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberAnswer.java new file mode 100644 index 00000000..a4ed1cf4 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberAnswer.java @@ -0,0 +1,65 @@ +package com.example.mody.domain.bodytype.entity.mapping; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QMemberAnswer is a Querydsl query type for MemberAnswer + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMemberAnswer extends EntityPathBase { + + private static final long serialVersionUID = 571481941L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QMemberAnswer memberAnswer = new QMemberAnswer("memberAnswer"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + public final com.example.mody.domain.bodytype.entity.QAnswer answer; + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.mody.domain.member.entity.QMember member; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QMemberAnswer(String variable) { + this(MemberAnswer.class, forVariable(variable), INITS); + } + + public QMemberAnswer(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QMemberAnswer(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QMemberAnswer(PathMetadata metadata, PathInits inits) { + this(MemberAnswer.class, metadata, inits); + } + + public QMemberAnswer(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.answer = inits.isInitialized("answer") ? new com.example.mody.domain.bodytype.entity.QAnswer(forProperty("answer"), inits.get("answer")) : null; + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberBodyType.java b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberBodyType.java new file mode 100644 index 00000000..7d38ecdf --- /dev/null +++ b/src/main/generated/com/example/mody/domain/bodytype/entity/mapping/QMemberBodyType.java @@ -0,0 +1,67 @@ +package com.example.mody.domain.bodytype.entity.mapping; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QMemberBodyType is a Querydsl query type for MemberBodyType + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMemberBodyType extends EntityPathBase { + + private static final long serialVersionUID = 1640559059L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QMemberBodyType memberBodyType = new QMemberBodyType("memberBodyType"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + public final StringPath body = createString("body"); + + public final com.example.mody.domain.bodytype.entity.QBodyType bodyType; + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.mody.domain.member.entity.QMember member; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QMemberBodyType(String variable) { + this(MemberBodyType.class, forVariable(variable), INITS); + } + + public QMemberBodyType(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QMemberBodyType(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QMemberBodyType(PathMetadata metadata, PathInits inits) { + this(MemberBodyType.class, metadata, inits); + } + + public QMemberBodyType(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.bodyType = inits.isInitialized("bodyType") ? new com.example.mody.domain.bodytype.entity.QBodyType(forProperty("bodyType")) : null; + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/file/entity/QBackupFile.java b/src/main/generated/com/example/mody/domain/file/entity/QBackupFile.java new file mode 100644 index 00000000..cfd4ebfb --- /dev/null +++ b/src/main/generated/com/example/mody/domain/file/entity/QBackupFile.java @@ -0,0 +1,54 @@ +package com.example.mody.domain.file.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QBackupFile is a Querydsl query type for BackupFile + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QBackupFile extends EntityPathBase { + + private static final long serialVersionUID = -1486765701L; + + public static final QBackupFile backupFile = new QBackupFile("backupFile"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final StringPath fileName = createString("fileName"); + + public final NumberPath fileSize = createNumber("fileSize", Long.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath s3Url = createString("s3Url"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QBackupFile(String variable) { + super(BackupFile.class, forVariable(variable)); + } + + public QBackupFile(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBackupFile(PathMetadata metadata) { + super(BackupFile.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/mody/domain/member/entity/QMember.java b/src/main/generated/com/example/mody/domain/member/entity/QMember.java new file mode 100644 index 00000000..51045878 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/member/entity/QMember.java @@ -0,0 +1,89 @@ +package com.example.mody.domain.member.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QMember is a Querydsl query type for Member + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMember extends EntityPathBase { + + private static final long serialVersionUID = -372438059L; + + public static final QMember member = new QMember("member1"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + public final DatePath birthDate = createDate("birthDate", java.time.LocalDate.class); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final StringPath email = createString("email"); + + public final EnumPath gender = createEnum("gender", com.example.mody.domain.member.enums.Gender.class); + + public final NumberPath height = createNumber("height", Integer.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final BooleanPath isRegistrationCompleted = createBoolean("isRegistrationCompleted"); + + public final NumberPath likeCount = createNumber("likeCount", Integer.class); + + public final ListPath likes = this.createList("likes", com.example.mody.domain.post.entity.mapping.MemberPostLike.class, com.example.mody.domain.post.entity.mapping.QMemberPostLike.class, PathInits.DIRECT2); + + public final EnumPath loginType = createEnum("loginType", com.example.mody.domain.member.enums.LoginType.class); + + public final ListPath memberBodyType = this.createList("memberBodyType", com.example.mody.domain.bodytype.entity.mapping.MemberBodyType.class, com.example.mody.domain.bodytype.entity.mapping.QMemberBodyType.class, PathInits.DIRECT2); + + public final StringPath nickname = createString("nickname"); + + public final StringPath password = createString("password"); + + public final ListPath posts = this.createList("posts", com.example.mody.domain.post.entity.Post.class, com.example.mody.domain.post.entity.QPost.class, PathInits.DIRECT2); + + public final StringPath profileImageUrl = createString("profileImageUrl"); + + public final StringPath provider = createString("provider"); + + public final StringPath providerId = createString("providerId"); + + public final ListPath recommendations = this.createList("recommendations", com.example.mody.domain.recommendation.entity.Recommendation.class, com.example.mody.domain.recommendation.entity.QRecommendation.class, PathInits.DIRECT2); + + public final ListPath RecommendLikes = this.createList("RecommendLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2); + + public final NumberPath reportCount = createNumber("reportCount", Integer.class); + + public final EnumPath role = createEnum("role", com.example.mody.domain.member.enums.Role.class); + + public final EnumPath status = createEnum("status", com.example.mody.domain.member.enums.Status.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QMember(String variable) { + super(Member.class, forVariable(variable)); + } + + public QMember(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QMember(PathMetadata metadata) { + super(Member.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/mody/domain/post/entity/QPost.java b/src/main/generated/com/example/mody/domain/post/entity/QPost.java new file mode 100644 index 00000000..e2b82896 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/post/entity/QPost.java @@ -0,0 +1,77 @@ +package com.example.mody.domain.post.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QPost is a Querydsl query type for Post + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QPost extends EntityPathBase { + + private static final long serialVersionUID = 874386081L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QPost post = new QPost("post"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + public final com.example.mody.domain.bodytype.entity.QBodyType bodyType; + + public final StringPath content = createString("content"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final ListPath images = this.createList("images", PostImage.class, QPostImage.class, PathInits.DIRECT2); + + public final BooleanPath isPublic = createBoolean("isPublic"); + + public final NumberPath likeCount = createNumber("likeCount", Integer.class); + + public final ListPath likes = this.createList("likes", com.example.mody.domain.post.entity.mapping.MemberPostLike.class, com.example.mody.domain.post.entity.mapping.QMemberPostLike.class, PathInits.DIRECT2); + + public final com.example.mody.domain.member.entity.QMember member; + + public final NumberPath reportCount = createNumber("reportCount", Integer.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QPost(String variable) { + this(Post.class, forVariable(variable), INITS); + } + + public QPost(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QPost(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QPost(PathMetadata metadata, PathInits inits) { + this(Post.class, metadata, inits); + } + + public QPost(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.bodyType = inits.isInitialized("bodyType") ? new com.example.mody.domain.bodytype.entity.QBodyType(forProperty("bodyType")) : null; + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/post/entity/QPostImage.java b/src/main/generated/com/example/mody/domain/post/entity/QPostImage.java new file mode 100644 index 00000000..c1792073 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/post/entity/QPostImage.java @@ -0,0 +1,64 @@ +package com.example.mody.domain.post.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QPostImage is a Querydsl query type for PostImage + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QPostImage extends EntityPathBase { + + private static final long serialVersionUID = 2094080826L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QPostImage postImage = new QPostImage("postImage"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final QPost post; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public final StringPath url = createString("url"); + + public QPostImage(String variable) { + this(PostImage.class, forVariable(variable), INITS); + } + + public QPostImage(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QPostImage(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QPostImage(PathMetadata metadata, PathInits inits) { + this(PostImage.class, metadata, inits); + } + + public QPostImage(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.post = inits.isInitialized("post") ? new QPost(forProperty("post"), inits.get("post")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/post/entity/mapping/QMemberPostLike.java b/src/main/generated/com/example/mody/domain/post/entity/mapping/QMemberPostLike.java new file mode 100644 index 00000000..3d9ecd73 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/post/entity/mapping/QMemberPostLike.java @@ -0,0 +1,65 @@ +package com.example.mody.domain.post.entity.mapping; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QMemberPostLike is a Querydsl query type for MemberPostLike + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMemberPostLike extends EntityPathBase { + + private static final long serialVersionUID = -1295047182L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QMemberPostLike memberPostLike = new QMemberPostLike("memberPostLike"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.mody.domain.member.entity.QMember member; + + public final com.example.mody.domain.post.entity.QPost post; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QMemberPostLike(String variable) { + this(MemberPostLike.class, forVariable(variable), INITS); + } + + public QMemberPostLike(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QMemberPostLike(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QMemberPostLike(PathMetadata metadata, PathInits inits) { + this(MemberPostLike.class, metadata, inits); + } + + public QMemberPostLike(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + this.post = inits.isInitialized("post") ? new com.example.mody.domain.post.entity.QPost(forProperty("post"), inits.get("post")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/post/entity/mapping/QPostReport.java b/src/main/generated/com/example/mody/domain/post/entity/mapping/QPostReport.java new file mode 100644 index 00000000..c6b717f7 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/post/entity/mapping/QPostReport.java @@ -0,0 +1,65 @@ +package com.example.mody.domain.post.entity.mapping; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QPostReport is a Querydsl query type for PostReport + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QPostReport extends EntityPathBase { + + private static final long serialVersionUID = 902725461L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QPostReport postReport = new QPostReport("postReport"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.mody.domain.member.entity.QMember member; + + public final com.example.mody.domain.post.entity.QPost post; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QPostReport(String variable) { + this(PostReport.class, forVariable(variable), INITS); + } + + public QPostReport(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QPostReport(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QPostReport(PathMetadata metadata, PathInits inits) { + this(PostReport.class, metadata, inits); + } + + public QPostReport(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + this.post = inits.isInitialized("post") ? new com.example.mody.domain.post.entity.QPost(forProperty("post"), inits.get("post")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/recommendation/dto/response/QRecommendResponse.java b/src/main/generated/com/example/mody/domain/recommendation/dto/response/QRecommendResponse.java new file mode 100644 index 00000000..6f430bb7 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/recommendation/dto/response/QRecommendResponse.java @@ -0,0 +1,21 @@ +package com.example.mody.domain.recommendation.dto.response; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.ConstructorExpression; +import javax.annotation.processing.Generated; + +/** + * com.example.mody.domain.recommendation.dto.response.QRecommendResponse is a Querydsl Projection type for RecommendResponse + */ +@Generated("com.querydsl.codegen.DefaultProjectionSerializer") +public class QRecommendResponse extends ConstructorExpression { + + private static final long serialVersionUID = 1780340304L; + + public QRecommendResponse(com.querydsl.core.types.Expression memberId, com.querydsl.core.types.Expression nickname, com.querydsl.core.types.Expression recommendationId, com.querydsl.core.types.Expression recommendType, com.querydsl.core.types.Expression isLiked, com.querydsl.core.types.Expression title, com.querydsl.core.types.Expression content, com.querydsl.core.types.Expression imageUrl) { + super(RecommendResponse.class, new Class[]{long.class, String.class, long.class, com.example.mody.domain.recommendation.enums.RecommendType.class, boolean.class, String.class, String.class, String.class}, memberId, nickname, recommendationId, recommendType, isLiked, title, content, imageUrl); + } + +} + diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java b/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java new file mode 100644 index 00000000..e5e23219 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java @@ -0,0 +1,74 @@ +package com.example.mody.domain.recommendation.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QRecommendation is a Querydsl query type for Recommendation + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QRecommendation extends EntityPathBase { + + private static final long serialVersionUID = 1413846483L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QRecommendation recommendation = new QRecommendation("recommendation"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + public final StringPath content = createString("content"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath imageUrl = createString("imageUrl"); + + public final NumberPath likeCount = createNumber("likeCount", Integer.class); + + public final com.example.mody.domain.member.entity.QMember member; + + public final ListPath RecommendLikes = this.createList("RecommendLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2); + + public final EnumPath recommendType = createEnum("recommendType", com.example.mody.domain.recommendation.enums.RecommendType.class); + + public final StringPath title = createString("title"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QRecommendation(String variable) { + this(Recommendation.class, forVariable(variable), INITS); + } + + public QRecommendation(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QRecommendation(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QRecommendation(PathMetadata metadata, PathInits inits) { + this(Recommendation.class, metadata, inits); + } + + public QRecommendation(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/category/QAppealCategory.java b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QAppealCategory.java new file mode 100644 index 00000000..9741fcaf --- /dev/null +++ b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QAppealCategory.java @@ -0,0 +1,50 @@ +package com.example.mody.domain.recommendation.entity.category; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QAppealCategory is a Querydsl query type for AppealCategory + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QAppealCategory extends EntityPathBase { + + private static final long serialVersionUID = -2052142589L; + + public static final QAppealCategory appealCategory = new QAppealCategory("appealCategory"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QAppealCategory(String variable) { + super(AppealCategory.class, forVariable(variable)); + } + + public QAppealCategory(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QAppealCategory(PathMetadata metadata) { + super(AppealCategory.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/category/QStyleCategory.java b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QStyleCategory.java new file mode 100644 index 00000000..d4506081 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/recommendation/entity/category/QStyleCategory.java @@ -0,0 +1,50 @@ +package com.example.mody.domain.recommendation.entity.category; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QStyleCategory is a Querydsl query type for StyleCategory + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QStyleCategory extends EntityPathBase { + + private static final long serialVersionUID = 1183613113L; + + public static final QStyleCategory styleCategory = new QStyleCategory("styleCategory"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QStyleCategory(String variable) { + super(StyleCategory.class, forVariable(variable)); + } + + public QStyleCategory(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QStyleCategory(PathMetadata metadata) { + super(StyleCategory.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/mapping/QMemberRecommendationLike.java b/src/main/generated/com/example/mody/domain/recommendation/entity/mapping/QMemberRecommendationLike.java new file mode 100644 index 00000000..0def67a8 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/recommendation/entity/mapping/QMemberRecommendationLike.java @@ -0,0 +1,65 @@ +package com.example.mody.domain.recommendation.entity.mapping; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QMemberRecommendationLike is a Querydsl query type for MemberRecommendationLike + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMemberRecommendationLike extends EntityPathBase { + + private static final long serialVersionUID = -2009073692L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QMemberRecommendationLike memberRecommendationLike = new QMemberRecommendationLike("memberRecommendationLike"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final com.example.mody.domain.member.entity.QMember member; + + public final com.example.mody.domain.recommendation.entity.QRecommendation recommendation; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QMemberRecommendationLike(String variable) { + this(MemberRecommendationLike.class, forVariable(variable), INITS); + } + + public QMemberRecommendationLike(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QMemberRecommendationLike(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QMemberRecommendationLike(PathMetadata metadata, PathInits inits) { + this(MemberRecommendationLike.class, metadata, inits); + } + + public QMemberRecommendationLike(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.member = inits.isInitialized("member") ? new com.example.mody.domain.member.entity.QMember(forProperty("member")) : null; + this.recommendation = inits.isInitialized("recommendation") ? new com.example.mody.domain.recommendation.entity.QRecommendation(forProperty("recommendation"), inits.get("recommendation")) : null; + } + +} + diff --git a/src/main/generated/com/example/mody/domain/test/entity/QTest.java b/src/main/generated/com/example/mody/domain/test/entity/QTest.java new file mode 100644 index 00000000..234f9a9c --- /dev/null +++ b/src/main/generated/com/example/mody/domain/test/entity/QTest.java @@ -0,0 +1,50 @@ +package com.example.mody.domain.test.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QTest is a Querydsl query type for Test + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QTest extends EntityPathBase { + + private static final long serialVersionUID = 936710021L; + + public static final QTest test = new QTest("test"); + + public final com.example.mody.global.common.base.QBaseEntity _super = new com.example.mody.global.common.base.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QTest(String variable) { + super(Test.class, forVariable(variable)); + } + + public QTest(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QTest(PathMetadata metadata) { + super(Test.class, metadata); + } + +} + diff --git a/src/main/generated/com/example/mody/global/common/base/QBaseEntity.java b/src/main/generated/com/example/mody/global/common/base/QBaseEntity.java new file mode 100644 index 00000000..f9f3f002 --- /dev/null +++ b/src/main/generated/com/example/mody/global/common/base/QBaseEntity.java @@ -0,0 +1,41 @@ +package com.example.mody.global.common.base; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QBaseEntity is a Querydsl query type for BaseEntity + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QBaseEntity extends EntityPathBase { + + private static final long serialVersionUID = 425151923L; + + public static final QBaseEntity baseEntity = new QBaseEntity("baseEntity"); + + public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); + + public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); + + public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.LocalDateTime.class); + + public QBaseEntity(String variable) { + super(BaseEntity.class, forVariable(variable)); + } + + public QBaseEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBaseEntity(PathMetadata metadata) { + super(BaseEntity.class, metadata); + } + +} + diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java index f69d93ea..bf1d12f9 100644 --- a/src/main/java/com/example/mody/ModyApplication.java +++ b/src/main/java/com/example/mody/ModyApplication.java @@ -2,8 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +// @SpringBootApplication +@EnableJpaAuditing public class ModyApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..25e39852 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java @@ -0,0 +1,512 @@ +package com.example.mody.domain.auth.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.mody.domain.auth.dto.request.EmailRequest; +import com.example.mody.domain.auth.dto.request.EmailVerificationRequest; +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; +import com.example.mody.domain.auth.dto.request.MemberLoginReqeust; +import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; +import com.example.mody.domain.auth.dto.response.AccessTokenResponse; +import com.example.mody.domain.auth.dto.response.LoginResponse; +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.auth.service.AuthCommandService; +import com.example.mody.domain.auth.service.email.EmailService; +import com.example.mody.domain.member.service.MemberCommandService; +import com.example.mody.global.common.base.BaseResponse; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AuthErrorStatus; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Auth API", description = "인증 관련 API - 회원가입, 로그인, 토큰 재발급, 로그아웃 등의 기능을 제공합니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private static final Logger log = LoggerFactory.getLogger(AuthController.class); + private final AuthCommandService authCommandService; + private final MemberCommandService memberCommandService; + private final EmailService emailService; + + @Operation( + summary = "회원가입 완료", + description = """ + 소셜 로그인 혹은 자체 회원가입 후 추가 정보를 입력받아 회원가입을 완료합니다. + - 카카오 로그인 시, 로그인 성공 후 신규 회원인 경우 호출해야하는 API입니다. + - 자체 회원 가입 시, /auth/signup 성공 후 /auth/login으로 로그인 후 호출해야하는 API입니다. + (자체 회원가입 API에서는 이메일, 비밀번호만 입력받고 현재 API를 호출하여 회원가입을 완료합니다.) + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "회원가입 완료 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "COMMON200", + "message": "요청에 성공하였습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "COMMON400", + "message": "필수 정보가 누락되었습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "사용자를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "MEMBER404", + "message": "해당 회원을 찾을 수 없습니다.", + "result": null + } + """ + ) + ) + ) + }) + @PostMapping("/signup/complete") + public BaseResponse completeRegistration( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody + @Parameter( + description = "회원가입 완료 요청 정보", + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MemberRegistrationRequest.class), + examples = @ExampleObject( + value = """ + { + "nickname": "사용자닉네임", + "birthDate": "2000-01-01", + "gender": "MALE", + "height": 180, + "profileImageUrl": "https://example.com/profile.jpg" + } + """ + ) + ) + ) MemberRegistrationRequest request + ) { + memberCommandService.completeRegistration(userDetails.getMember().getId(), request); + return BaseResponse.onSuccess(null); + } + + @Operation( + summary = "토큰 재발급", + description = """ + Access Token이 만료되었을 때 Refresh Token을 사용하여 새로운 토큰을 발급받습니다. + Refresh Token은 쿠키에서 자동으로 추출되며, 새로운 Access Token과 Refresh Token이 발급됩니다. + 발급된 Access Token은 응답 헤더의 Authorization에, Refresh Token은 쿠키에 포함됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + headers = { + @Header( + name = "Authorization", + description = "새로 발급된 Access Token", + schema = @Schema(type = "string", example = "Bearer eyJhbGciOiJIUzI1...") + ), + @Header( + name = "Set-Cookie", + description = "새로 발급된 Refresh Token (HttpOnly Cookie)", + schema = @Schema(type = "string", example = "refresh_token=eyJhbGciOiJIUzI1...; Path=/; HttpOnly; Secure; SameSite=Strict") + ) + }, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "COMMON200", + "message": "요청에 성공하였습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "REFRESH_TOKEN404", + description = "유효하지 않은 리프레쉬 토큰일 때 발생합니다. (디비에 동일한 리프레쉬 토큰이 없을 때 발생)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "REFRESH_TOKEN404", + "message": "REFRESH TOKEN이 유효하지 않습니다.", + "result": null + } + """ + ) + ) + ) + }) + @PostMapping("/reissue") + public BaseResponse reissueToken( + @CookieValue(name = "refresh_token") String refreshToken, + HttpServletResponse response + ) { + log.info("refreshToken: {}", refreshToken); + return BaseResponse.onSuccess(authCommandService.reissueToken(refreshToken, response)); + } + + @Operation( + summary = "로그아웃", + description = """ + 사용자를 로그아웃 처리합니다. + 서버에서 Refresh Token을 삭제하고, 클라이언트의 쿠키에서도 Refresh Token을 제거합니다. + 클라이언트에서는 저장된 Access Token도 함께 삭제해야 합니다. + """, + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그아웃 성공", + headers = { + @Header( + name = "Set-Cookie", + description = "Refresh Token 제거를 위한 쿠키", + schema = @Schema(type = "string", example = "refresh_token=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict") + ) + }, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "COMMON200", + "message": "요청에 성공하였습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "AUTH401", + description = "인증되지 않은 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "AUTH001", + "message": "JWT가 없습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "REFRESH_TOKEN404", + description = "유효하지 않은 Refresh Token", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "REFRESH_TOKEN404", + "message": "REFRESH TOKEN이 유효하지 않습니다.", + "result": null + } + """ + ) + ) + ) + }) + @PostMapping("/logout") + public BaseResponse logout( + @CookieValue(name = "refresh_token") + @Parameter( + description = "리프레시 토큰 (쿠키에서 자동 추출)", + required = true + ) String refreshToken, + HttpServletResponse response + ) { + authCommandService.logout(refreshToken); + + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(0) + .path("/") + .build(); + + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + return BaseResponse.onSuccess(null); + } + + @Operation( + summary = "일반 회원가입 API", + description = "이메일과 비밀번호를 사용하여 새로운 회원을 등록합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "회원가입 성공", + headers = { + @io.swagger.v3.oas.annotations.headers.Header( + name = "Authorization", + description = "Access Token", + schema = @Schema(type = "string", example = "Bearer eyJhbGciOiJIUzI1...") + ), + @io.swagger.v3.oas.annotations.headers.Header( + name = "Set-Cookie", + description = "Refresh Token (HttpOnly Cookie)", + schema = @Schema(type = "string") + ) + }, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class) + ) + ), + @ApiResponse( + responseCode = "COMMON400", + description = "잘못된 요청 데이터", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "COMMON400", + "message": "유효하지 않은 이메일 형식입니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "MEMBER409", + description = "이미 존재하는 이메일", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "MEMBER409", + "message": "이미 등록된 이메일입니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "COMMON402", + description = "올바르지 않은 비밀번호 형식", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-27T00:08:57.1421127", + "code": "COMMON402", + "message": "Validation Error입니다.", + "result": { + "password": "비밀번호는 8자 이상, 영어와 숫자, 그리고 특수문자(@$!%*?&#)를 포함해야 하며, 한글은 사용할 수 없습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/signup") + public BaseResponse joinMember( + @Valid @RequestBody + @Parameter( + description = "회원가입 요청 정보", + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MemberJoinRequest.class), + examples = @ExampleObject( + value = """ + { + "email": "user@example.com", + "password": "Password123!", + "nickname": "사용자닉네임" + } + """ + ) + ) + ) MemberJoinRequest request, + HttpServletResponse response + ) { + LoginResponse loginResponse = memberCommandService.joinMember(request, response); + return BaseResponse.onSuccess(loginResponse); + } + + /** + * Swagger 명세를 위한 테스트 controller + * 실제 동작은 이 controller를 거치지 않고 JwtLoginFilter를 통해 이루어집니다. + * @param loginRequest + * @return + */ + @Operation( + summary = "로그인 API", + description = "이메일과 비밀번호를 사용하여 로그인합니다. 성공 시 Access Token과 Refresh Token이 발급됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + headers = { + @io.swagger.v3.oas.annotations.headers.Header( + name = "Authorization", + description = "Access Token", + schema = @Schema(type = "string", example = "Bearer eyJhbGciOiJIUzI1...") + ), + @io.swagger.v3.oas.annotations.headers.Header( + name = "Set-Cookie", + description = "Refresh Token (HttpOnly Cookie)", + schema = @Schema(type = "string") + ) + }, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class) + ) + ), + @ApiResponse( + responseCode = "AUTH_INVALID_PASSWORD", + description = "올바르지 않은 비밀번호를 입력함", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "AUTH_INVALID_PASSWORD", + "message": "비밀번호가 올바르지 않습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "MEMBER404", + description = "입력한 이메일을 가진 사용자가 없을 때 발생", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-27T00:12:53.6087824", + "code": "MEMBER404", + "message": "해당 회원을 찾을 수 없습니다." + } + """ + ) + ) + ) + }) + @PostMapping("/login") + public BaseResponse authLogin( + @RequestBody @Valid + @Parameter( + description = "로그인 요청 정보", + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MemberLoginReqeust.class), + examples = @ExampleObject( + value = """ + { + "email": "user@example.com", + "password": "Password123!" + } + """ + ) + ) + ) MemberLoginReqeust loginRequest + ) { + + return BaseResponse.onSuccess(null); + } + + @Operation(summary = "인증 메일 발송", description = "입력된 이메일로 인증번호를 발송합니다.") + @PostMapping("/email/verify/send") + public BaseResponse sendVerificationEmail(@Valid @RequestBody EmailRequest request) { + emailService.sendVerificationEmail(request.getEmail()); + return BaseResponse.onSuccess(null); + } + + @Operation(summary = "이메일 인증", description = "발송된 인증번호를 확인합니다.") + @PostMapping("/email/verify") + public BaseResponse verifyEmail(@Valid @RequestBody EmailVerificationRequest request) { + if (!emailService.verifyEmail(request.getEmail(), request.getVerificationCode())) { + throw new RestApiException(AuthErrorStatus.INVALID_VERIFICATION_CODE); + } + return BaseResponse.onSuccess(null); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/dto/TokenDto.java b/src/main/java/com/example/mody/domain/auth/dto/TokenDto.java new file mode 100644 index 00000000..1e1c2c94 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/TokenDto.java @@ -0,0 +1,17 @@ +package com.example.mody.domain.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TokenDto { + private String grantType; + private String accessToken; + private String refreshToken; + private Long accessTokenExpiresIn; +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/EmailRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/EmailRequest.java new file mode 100644 index 00000000..20e57747 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/request/EmailRequest.java @@ -0,0 +1,21 @@ +package com.example.mody.domain.auth.dto.request; + +import com.example.mody.domain.auth.exception.annotation.UniqueEmail; + +import jakarta.validation.constraints.Email; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class EmailRequest { + @Email + @UniqueEmail + private String email; +} + diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/EmailVerificationRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/EmailVerificationRequest.java new file mode 100644 index 00000000..b5b465d6 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/request/EmailVerificationRequest.java @@ -0,0 +1,20 @@ +package com.example.mody.domain.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class EmailVerificationRequest { + @Email + private String email; + @NotBlank + private String verificationCode; +} diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java new file mode 100644 index 00000000..8221e497 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.auth.dto.request; + +import com.example.mody.domain.member.enums.Gender; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Schema(description = "자체 회원가입 요청 DTO") +@Getter +@NoArgsConstructor +public class MemberJoinRequest { + @Schema( + description = "이메일", + example = "user@example.com" + ) + @NotNull(message = "이메일은 필수입니다") + @Email + private String email; + + @Schema( + description = "비밀번호", + example = "user1234!" + ) + @NotNull(message = "비밀번호는 필수입니다") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?&#])[A-Za-z\\d@$!%*?&#]{8,}$", + message = "비밀번호는 8자 이상, 영어와 숫자, 그리고 특수문자(@$!%*?&#)를 포함해야 하며, 한글은 사용할 수 없습니다." + ) + private String password; +} diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/MemberLoginReqeust.java b/src/main/java/com/example/mody/domain/auth/dto/request/MemberLoginReqeust.java new file mode 100644 index 00000000..ca24c064 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/request/MemberLoginReqeust.java @@ -0,0 +1,27 @@ +package com.example.mody.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MemberLoginReqeust { + + @Schema( + description = "이메일", + example = "user@example.com" + ) + @NotNull(message = "이메일은 필수입니다") + @Email + private String email; + + @Schema( + description = "비밀번호", + example = "user1234!" + ) + @NotNull(message = "비밀번호는 필수입니다") + private String password; +} diff --git a/src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java new file mode 100644 index 00000000..6b2d8945 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java @@ -0,0 +1,66 @@ +package com.example.mody.domain.auth.dto.request; + +import java.time.LocalDate; + +import com.example.mody.domain.member.enums.Gender; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원 가입할 때 필요한 정보들 + */ +@Schema(description = "회원가입 요청 DTO") +@Getter +@NoArgsConstructor +public class MemberRegistrationRequest { + + @Schema( + description = "닉네임", + example = "모디", + minLength = 2, + maxLength = 20 + ) + @NotBlank(message = "닉네임은 필수입니다") + private String nickname; + + @Schema( + description = "생년월일", + example = "1990-01-01", + type = "string", + format = "date" + ) + @NotNull(message = "생년월일은 필수입니다") + @Past(message = "생년월일은 과거 날짜여야 합니다") + private LocalDate birthDate; + + @Schema( + description = "성별", + example = "MALE", + allowableValues = {"MALE", "FEMALE"} + ) + @NotNull(message = "성별은 필수입니다") + private Gender gender; + + @Schema( + description = "키(cm)", + example = "170", + minimum = "100", + maximum = "250" + ) + @NotNull(message = "키는 필수입니다") + @Positive(message = "키는 양수여야 합니다") + private Integer height; + + @Schema( + description = "프로필 이미지 URL", + example = "https://my-bucket.s3.amazonaws.com/path/to/file.jpg", + required = false + ) + private String profileImageUrl; +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/AccessTokenResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/AccessTokenResponse.java new file mode 100644 index 00000000..d10fd404 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/response/AccessTokenResponse.java @@ -0,0 +1,17 @@ +package com.example.mody.domain.auth.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class AccessTokenResponse { + + private String accessToken; + +} diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java new file mode 100644 index 00000000..c82e9f09 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.auth.dto.response; + +import java.util.Map; + +import lombok.Getter; + +@Getter +public class KakaoResponse implements OAuth2Response { + private Map attribute; + private Map kakaoAccount; + private Map profile; + + public KakaoResponse(Map attribute) { + this.attribute = attribute; + this.kakaoAccount = (Map)attribute.get("kakao_account"); + this.profile = (Map)kakaoAccount.get("profile"); + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getName() { + return profile.get("nickname").toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java new file mode 100644 index 00000000..55e9cafa --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java @@ -0,0 +1,55 @@ +package com.example.mody.domain.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "로그인 응답 DTO") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class LoginResponse { + @Schema( + description = "회원 ID", + example = "1" + ) + private Long memberId; + + @Schema( + description = "닉네임", + example = "모디" + ) + private String nickname; + + @Schema( + description = "신규 회원 여부", + example = "true" + ) + private boolean isNewMember; + + @Schema( + description = "회원가입 완료 여부", + example = "false" + ) + private boolean isRegistrationCompleted; + + @Schema( + description = "access 토큰" + ) + private String accessToken; + + public static LoginResponse of(Long memberId, String nickname, boolean isNewMember, + boolean isRegistrationCompleted, String newAccessToken) { + return LoginResponse.builder() + .memberId(memberId) + .nickname(nickname) + .isNewMember(isNewMember) + .isRegistrationCompleted(isRegistrationCompleted) + .accessToken(newAccessToken) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/OAuth2Response.java b/src/main/java/com/example/mody/domain/auth/dto/response/OAuth2Response.java new file mode 100644 index 00000000..162bbda4 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/response/OAuth2Response.java @@ -0,0 +1,13 @@ +package com.example.mody.domain.auth.dto.response; + +public interface OAuth2Response { + + //제공자 (Ex. naver, google, ...) + String getProvider(); + + //제공자에서 발급해주는 아이디(번호) + String getProviderId(); + + //사용자 실명 (설정한 이름) + String getName(); +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/entity/RefreshToken.java b/src/main/java/com/example/mody/domain/auth/entity/RefreshToken.java new file mode 100644 index 00000000..7ab55a8d --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/entity/RefreshToken.java @@ -0,0 +1,49 @@ +package com.example.mody.domain.auth.entity; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.global.common.base.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicUpdate +@DynamicInsert +@Table(name = "refresh_token") +public class RefreshToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "refresh_token_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Column(nullable = false, unique = true) + private String token; + + public void updateToken(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/exception/annotation/UniqueEmail.java b/src/main/java/com/example/mody/domain/auth/exception/annotation/UniqueEmail.java new file mode 100644 index 00000000..0f2c7c77 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/exception/annotation/UniqueEmail.java @@ -0,0 +1,24 @@ +package com.example.mody.domain.auth.exception.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.mody.domain.auth.exception.validator.ExistsEmailValidator; + +import jakarta.validation.Constraint; + +@Documented +@Constraint(validatedBy = ExistsEmailValidator.class) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface UniqueEmail { + + String message() default "이미 존재하는 이메일입니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/mody/domain/auth/exception/validator/ExistsEmailValidator.java b/src/main/java/com/example/mody/domain/auth/exception/validator/ExistsEmailValidator.java new file mode 100644 index 00000000..7167cdf3 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/exception/validator/ExistsEmailValidator.java @@ -0,0 +1,38 @@ +package com.example.mody.domain.auth.exception.validator; + +import org.springframework.stereotype.Component; + +import com.example.mody.domain.auth.exception.annotation.UniqueEmail; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ExistsEmailValidator implements ConstraintValidator { + + private final MemberRepository memberRepository; + + @Override + public void initialize(UniqueEmail constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String email, ConstraintValidatorContext context) { + + if (memberRepository.existsByEmail(email)) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(MemberErrorStatus.EMAIL_ALREADY_EXISTS.getMessage()) + .addConstraintViolation(); + return false; + } + + return true; + + } + +} diff --git a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 00000000..ff993dbf --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,89 @@ +package com.example.mody.domain.auth.handler; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.mody.domain.auth.jwt.JwtProvider; +import com.example.mody.domain.auth.security.CustomOAuth2User; +import com.example.mody.domain.auth.service.AuthCommandService; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.Role; +import com.example.mody.domain.member.enums.Status; +import com.example.mody.domain.member.repository.MemberRepository; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final MemberRepository memberRepository; + private final AuthCommandService authCommandService; + + // 프론트 엔드 주소, 환경변수에서 주입 + @Value("${front.redirect-url.home}") + private String FRONT_HOME_URL; + + @Value("${front.redirect-url.signup}") + private String FRONT_SIGNUP_URL; + + /** + * OAuth2 로그인 성공 시 처리 + * @param request + * @param response + * @param authentication + * @throws IOException + * @throws ServletException + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + CustomOAuth2User oAuth2User = (CustomOAuth2User)authentication.getPrincipal(); + + // 회원 조회 또는 생성 + Member member = memberRepository.findByProviderId(oAuth2User.getOAuth2Response().getProviderId()) + .orElseGet(() -> saveMember(oAuth2User)); + + // 새로 가입한 멤버인지 아닌지 확인 + boolean isNewMember = member.getCreatedAt().equals(member.getUpdatedAt()); + + // Access Token, Refresh Token 발급 + String newAccessToken = authCommandService.processLoginSuccess(member, response); + + String tempUrl = (isNewMember || !member.isRegistrationCompleted()) ? FRONT_SIGNUP_URL : FRONT_HOME_URL; + + String targetUrl = UriComponentsBuilder.fromUriString(tempUrl) + .build().toUriString(); + + // 리다이렉션 수행 + response.sendRedirect(targetUrl); + + } + + private Member saveMember(CustomOAuth2User oAuth2User) { + Member member = Member.builder() + .providerId(oAuth2User.getOAuth2Response().getProviderId()) + .provider(oAuth2User.getOAuth2Response().getProvider()) + .nickname(oAuth2User.getOAuth2Response().getName()) + .status(Status.ACTIVE) + .role(Role.ROLE_USER) + .isRegistrationCompleted(false) + .build(); + + return memberRepository.save(member); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..11599ba8 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,117 @@ +package com.example.mody.domain.auth.jwt; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.domain.member.service.MemberQueryService; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AuthErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * JWT 토큰을 검증하고 인증 정보를 SecurityContextHolder에 저장하는 필터 + */ +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final MemberRepository memberRepository; + private final ObjectMapper objectMapper; + private final MemberQueryService memberQueryService; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String uri = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty()) { + uri = uri.substring(contextPath.length()); + } + log.info("JwtAuthenticationFilter - Request URI after context removal: {}", uri); + boolean skip = uri.startsWith("/auth/") && !uri.startsWith("/auth/signup/complete") || + uri.startsWith("/oauth2/") || + uri.startsWith("/email/") || + uri.startsWith("/swagger-ui/") || + uri.startsWith("/v3/api-docs/"); + log.info("JwtAuthenticationFilter - shouldNotFilter returns: {}", skip); + return skip; + + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + // 헤더에서 토큰 추출 + String token = resolveToken(request); + + // 만약 토큰이 있다면 + if (token != null) { + String memberId = jwtProvider.validateTokenAndGetSubject(token); + Member member = memberRepository.findById(Long.parseLong(memberId)) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)); + + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + customUserDetails, + null, + List.of(new SimpleGrantedAuthority(member.getRole().toString())) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } catch (RestApiException e) { + SecurityContextHolder.clearContext(); + sendErrorResponse(response, e); + } catch (Exception e) { + SecurityContextHolder.clearContext(); + sendErrorResponse(response, new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)); + } + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + private void sendErrorResponse(HttpServletResponse response, RestApiException exception) throws IOException { + response.setStatus(exception.getErrorCode().getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + Map errorResponse = Map.of( + "timestamp", java.time.LocalDateTime.now().toString(), + "code", exception.getErrorCode().getCode(), + "message", exception.getErrorCode().getMessage() + ); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java new file mode 100644 index 00000000..0f8d319e --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java @@ -0,0 +1,118 @@ +package com.example.mody.domain.auth.jwt; + +import com.example.mody.domain.auth.dto.request.MemberLoginReqeust; +import com.example.mody.domain.auth.dto.response.LoginResponse; +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.auth.service.AuthCommandService; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.base.BaseResponse; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AuthErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.IOException; + + +@RequiredArgsConstructor +public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JwtProvider jwtProvider; + private final AuthCommandService authCommandService; + private final MemberRepository memberRepository; + private final ObjectMapper objectMapper; + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + + //Json형식 데이터 추출 + try { + MemberLoginReqeust loginReqeust = objectMapper.readValue(request.getInputStream(), + MemberLoginReqeust.class); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + loginReqeust.getEmail(), loginReqeust.getPassword(), null); + + //authenticationManager가 이메일, 비밀번호로 검증을 진행 + return authenticationManager.authenticate(authToken); + + } catch (IOException e) { + throw new RuntimeException("Failed to parse authentication request body", e); + } + } + + //로그인 성공시, JWT토큰 발급 + @Override + public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + + CustomUserDetails customUserDetails = (CustomUserDetails)authResult.getPrincipal(); + + String username = customUserDetails.getUsername(); + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ID_TOKEN)); + + // Access Token, Refresh Token 발급 + String newAccessToken = authCommandService.processLoginSuccess(member, response); + + // 로그인 응답 데이터 설정 + LoginResponse loginResponse = LoginResponse.of( + member.getId(), + member.getNickname(), + false, + member.isRegistrationCompleted(), + newAccessToken + ); + + // 응답 바디 작성 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(BaseResponse.onSuccess(loginResponse))); + } + + //로그인 실패한 경우 응답처리 + @Override + public void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + String errorMessage; + String errorCode = "AUTH401"; // 기본 에러 코드 + + //존재하지 않는 이메일인 경우, 비밀번호가 올라바르지 않은 경우에 따른 예외처리 + if (failed.getCause() instanceof RestApiException) { + RestApiException restApiException = (RestApiException)failed.getCause(); + errorMessage = restApiException.getErrorCode().getMessage(); //"해당 회원은 존재하지 않습니다." + errorCode = restApiException.getErrorCode().getCode(); + } else if (failed instanceof BadCredentialsException) { + errorMessage = "비밀번호가 올바르지 않습니다."; + errorCode = "AUTH_INVALID_PASSWORD"; + } else { + errorMessage = "인증에 실패했습니다."; + } + + // JSON 응답 작성 + BaseResponse errorResponse = BaseResponse.onFailure(errorCode, errorMessage, null); + String jsonResponse = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponse); + } + +} diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java new file mode 100644 index 00000000..77899a37 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java @@ -0,0 +1,84 @@ +package com.example.mody.domain.auth.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AuthErrorStatus; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; + +/** + * JWT 토큰 생성 및 검증을 담당하는 클래스 + */ +@Slf4j +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long accessTokenValidityInMilliseconds; + private final long refreshTokenValidityInMilliseconds; + + public JwtProvider( + // secret key + @Value("${jwt.secret}") final String secretKey, + // access token 유효 시간 + @Value("${jwt.accessExpiration}") final long accessTokenValidityInMilliseconds, + // refresh token 유효 시간 + @Value("${jwt.refreshExpiration}") final long refreshTokenValidityInMilliseconds + ) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; + this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds; + } + + // access token 생성 + public String createAccessToken(String subject) { + return createToken(subject, accessTokenValidityInMilliseconds); + } + + // refresh token 생성 + public String createRefreshToken(String subject) { + return createToken(subject, refreshTokenValidityInMilliseconds); + } + + // 토큰 생성 + private String createToken(String subject, long validityInMilliseconds) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .subject(subject) + .issuedAt(now) + .expiration(validity) + .signWith(secretKey) + .compact(); + } + + // 토큰 검증 및 subject 반환 + public String validateTokenAndGetSubject(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } catch (ExpiredJwtException e) { + throw new RestApiException(AuthErrorStatus.EXPIRED_MEMBER_JWT); + } catch (UnsupportedJwtException e) { + throw new RestApiException(AuthErrorStatus.UNSUPPORTED_JWT); + } catch (Exception e) { + throw new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN); + } + } +} diff --git a/src/main/java/com/example/mody/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/com/example/mody/domain/auth/repository/EmailVerificationRepository.java new file mode 100644 index 00000000..e890f0bb --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/repository/EmailVerificationRepository.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.auth.repository; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class EmailVerificationRepository { + + private static final String KEY_PREFIX = "email:verification:"; + private final StringRedisTemplate redisTemplate; + + public void saveVerificationCode(String email, String code, Duration duration) { + redisTemplate.opsForValue() + .set(KEY_PREFIX + email, code, duration); + } + + public String getVerificationCode(String email) { + return redisTemplate.opsForValue().get(KEY_PREFIX + email); + } + + public void removeVerificationCode(String email) { + redisTemplate.delete(KEY_PREFIX + email); + } + + public boolean hasKey(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + email)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..aa174adb --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,16 @@ +package com.example.mody.domain.auth.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.mody.domain.auth.entity.RefreshToken; +import com.example.mody.domain.member.entity.Member; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + + Optional findByMember(Member member); + + boolean existsByToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/security/CustomOAuth2User.java b/src/main/java/com/example/mody/domain/auth/security/CustomOAuth2User.java new file mode 100644 index 00000000..c16beae6 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/security/CustomOAuth2User.java @@ -0,0 +1,44 @@ +package com.example.mody.domain.auth.security; + +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.mody.domain.auth.dto.response.OAuth2Response; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + + private final OAuth2Response oAuth2Response; + private final Map attributes; + private final boolean isRegistrationCompleted; + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + authorities.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return "ROLE_USER"; + } + }); + return authorities; + } + + @Override + public String getName() { + return oAuth2Response.getName(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java new file mode 100644 index 00000000..7aa87361 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java @@ -0,0 +1,70 @@ +package com.example.mody.domain.auth.security; + +import java.util.ArrayList; +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.example.mody.domain.member.entity.Member; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Member member; + + //Role return + @Override + public Collection getAuthorities() { + + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + + return member.getRole().name(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public Member getMember() { + return this.member; + } +} diff --git a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java new file mode 100644 index 00000000..a365ef69 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package com.example.mody.domain.auth.security; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.example.mody.domain.exception.MemberException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + //데이터베이스에서 사용자의 정보를 조회 + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + // 소셜 로그인 사용자인지 확인 + if (member.getProvider() != null) { + throw new MemberException(MemberErrorStatus.INVALID_LOGIN_TYPE); + } + + return new CustomUserDetails(member); + } +} diff --git a/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java new file mode 100644 index 00000000..b68246ab --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java @@ -0,0 +1,69 @@ +package com.example.mody.domain.auth.security; + +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.mody.domain.auth.dto.response.KakaoResponse; +import com.example.mody.domain.auth.dto.response.OAuth2Response; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.LoginType; +import com.example.mody.domain.member.enums.Role; +import com.example.mody.domain.member.enums.Status; +import com.example.mody.domain.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + /** + * OAuth2UserRequest를 통해 OAuth2User를 데이터베이스에서 로드하고 회원가입 및 로그인 처리 + * @param userRequest + * @return + * @throws OAuth2AuthenticationException + */ + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + final OAuth2Response oAuth2Response; + + // 카카오 로그인 + if (registrationId.equals("kakao")) { + oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); + } else { + return null; + } + + // 회원 정보를 조회. + // 만약 회원이 없다면 회원을 저장함. + // 그 다음 AuthController의 회원가입 API를 통해 회원가입을 완료해야 함. + Member member = memberRepository.findByProviderIdAndLoginType( + oAuth2Response.getProviderId(), + LoginType.KAKAO + ) + .orElseGet(() -> saveMember(oAuth2Response)); + + return new CustomOAuth2User(oAuth2Response, oAuth2User.getAttributes(), member.isRegistrationCompleted()); + } + + private Member saveMember(OAuth2Response oAuth2Response) { + Member member = Member.builder() + .providerId(oAuth2Response.getProviderId()) + .provider(oAuth2Response.getProvider()) + .nickname(oAuth2Response.getName()) + .status(Status.ACTIVE) + .role(Role.ROLE_USER) + .loginType(LoginType.KAKAO) + .isRegistrationCompleted(false) + .build(); + + return memberRepository.save(member); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java new file mode 100644 index 00000000..40aea7f2 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java @@ -0,0 +1,17 @@ +package com.example.mody.domain.auth.service; + +import com.example.mody.domain.auth.dto.response.AccessTokenResponse; +import com.example.mody.domain.member.entity.Member; + +import jakarta.servlet.http.HttpServletResponse; + +public interface AuthCommandService { + + AccessTokenResponse reissueToken(String oldRefreshToken, HttpServletResponse response); + + void saveRefreshToken(Member member, String refreshToken); + + void logout(String refreshToken); + + String processLoginSuccess(Member member, HttpServletResponse response); +} diff --git a/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java new file mode 100644 index 00000000..4b6455d0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java @@ -0,0 +1,111 @@ +package com.example.mody.domain.auth.service; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.mody.domain.auth.dto.response.AccessTokenResponse; +import com.example.mody.domain.auth.entity.RefreshToken; +import com.example.mody.domain.auth.jwt.JwtProvider; +import com.example.mody.domain.auth.repository.RefreshTokenRepository; +import com.example.mody.domain.exception.RefreshTokenException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AuthErrorStatus; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class AuthCommandServiceImpl implements AuthCommandService { + + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + + public AccessTokenResponse reissueToken(String oldRefreshToken, HttpServletResponse response) { + log.info("Client refresh token: {}", oldRefreshToken); + RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(oldRefreshToken) + .orElseThrow(() -> { + log.warn("DB에 저장된 refresh token과 일치하는 값이 없습니다."); + return new RefreshTokenException(AuthErrorStatus.INVALID_REFRESH_TOKEN); + }); + log.info("DB refresh token for member {}: {}", refreshTokenEntity.getMember().getId(), + refreshTokenEntity.getToken()); + + // Refresh Token에 해당하는 회원 조회 + Member member = refreshTokenEntity.getMember(); + + // 새로운 토큰 발급 + String newAccessToken = jwtProvider.createAccessToken(member.getId().toString()); + String newRefreshToken = jwtProvider.createRefreshToken(member.getId().toString()); + + // Refresh Token 교체 (Rotation) + refreshTokenEntity.updateToken(newRefreshToken); + + // Refresh Token을 쿠키에 설정 + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", newRefreshToken) + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(7 * 24 * 60 * 60) // 7일 + .path("/") + .build(); + + // Access Token을 Authorization 헤더에 설정 + + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + + return AccessTokenResponse.builder() + .accessToken(newAccessToken) + .build(); + } + + public void saveRefreshToken(Member member, String refreshToken) { + // 기존 리프레시 토큰이 있다면 업데이트, 없다면 새로 생성 + RefreshToken refreshTokenEntity = refreshTokenRepository.findByMember(member) + .orElse(RefreshToken.builder() + .member(member) + .token(refreshToken) + .build()); + + refreshTokenEntity.updateToken(refreshToken); + refreshTokenRepository.save(refreshTokenEntity); + } + + public void logout(String refreshToken) { + RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_REFRESH_TOKEN)); + + refreshTokenRepository.delete(refreshTokenEntity); + } + + /** + * 로그인 성공 시, 엑세스 토큰과 리프레쉬 토큰을 발급하고 헤더에 넣는 코드를 공통으로 묶음. + */ + @Override + public String processLoginSuccess(Member member, HttpServletResponse response) { + // Access Token, Refresh Token 발급 + String newAccessToken = jwtProvider.createAccessToken(member.getId().toString()); + String newRefreshToken = jwtProvider.createRefreshToken(member.getId().toString()); + + // Refresh Token 저장 + saveRefreshToken(member, newRefreshToken); + + // Refresh Token을 쿠키에 설정 + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", newRefreshToken) + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(7 * 24 * 60 * 60) // 7일(7 * 24 * 60 * 60) + .path("/") + .build(); + + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + + return newAccessToken; + } +} diff --git a/src/main/java/com/example/mody/domain/auth/service/email/EmailService.java b/src/main/java/com/example/mody/domain/auth/service/email/EmailService.java new file mode 100644 index 00000000..8e2999e9 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/service/email/EmailService.java @@ -0,0 +1,8 @@ +package com.example.mody.domain.auth.service.email; + +public interface EmailService { + + void sendVerificationEmail(String email); + + boolean verifyEmail(String email, String code); +} diff --git a/src/main/java/com/example/mody/domain/auth/service/email/EmailServiceImpl.java b/src/main/java/com/example/mody/domain/auth/service/email/EmailServiceImpl.java new file mode 100644 index 00000000..5201f19f --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/service/email/EmailServiceImpl.java @@ -0,0 +1,69 @@ +package com.example.mody.domain.auth.service.email; + +import java.time.Duration; +import java.util.Random; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.GlobalErrorStatus; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class EmailServiceImpl implements EmailService { + private static final long VERIFICATION_TIME_LIMIT = 5L; + private final JavaMailSender emailSender; + private final StringRedisTemplate redisTemplate; + private final SpringTemplateEngine templateEngine; + + public void sendVerificationEmail(String email) { + try { + String verificationCode = generateVerificationCode(); + + // 템플릿에 전달할 데이터 설정 + Context context = new Context(); + context.setVariable("name", email.split("@")[0]); // 이메일 앞부분을 이름으로 사용 + context.setVariable("verificationCode", verificationCode); + + // 템플릿을 이용해 HTML 생성 + String htmlContent = templateEngine.process("mail/verification-email", context); + + // 이메일 메시지 생성 + MimeMessage message = emailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(email); + helper.setSubject("MODY 이메일 인증"); + helper.setText(htmlContent, true); // HTML 사용을 위해 true로 설정 + + // 이메일 발송 + emailSender.send(message); + + // Redis에 인증번호 저장 + redisTemplate.opsForValue() + .set(email, verificationCode, Duration.ofMinutes(VERIFICATION_TIME_LIMIT)); + + } catch (MessagingException e) { + throw new RestApiException(GlobalErrorStatus._INTERNAL_SERVER_ERROR); + } + } + + public boolean verifyEmail(String email, String code) { + String storedCode = redisTemplate.opsForValue().get(email); + return code.equals(storedCode); + } + + private String generateVerificationCode() { + Random random = new Random(); + return String.format("%06d", random.nextInt(1000000)); + } +} diff --git a/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java new file mode 100644 index 00000000..5e3ca045 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java @@ -0,0 +1,156 @@ +package com.example.mody.domain.bodytype.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.bodytype.dto.request.BodyTypeAnalysisRequest; +import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; +import com.example.mody.domain.bodytype.service.memberbodytype.MemberBodyTypeCommandService; +import com.example.mody.domain.bodytype.service.memberbodytype.MemberBodyTypeQueryService; +import com.example.mody.global.common.base.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "체형 분석", description = "체형 분석 API") +@RestController +@RequestMapping("/body-analysis") +@RequiredArgsConstructor +public class BodyTypeController { + + private final MemberBodyTypeCommandService memberBodyTypeCommandService; + private final MemberBodyTypeQueryService memberBodyTypeQueryService; + + @PostMapping("/result") + @Operation(summary = "체형 분석 API", description = "OpenAi를 사용해서 사용자의 체형을 분석하는 API입니다. Request Body에는 질문에 맞는 답변 목록을 보내주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "체형 분석 성공"), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "체형을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "BODY_TYPE404", + "message": "체형을 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "GPT가 적절한 응답을 하지 못 했습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "ANALYSIS108", + "message": "GPT가 올바르지 않은 답변을 했습니다. 관리자에게 문의하세요." + } + """ + ) + ) + ), + }) + public BaseResponse analyzeBodyType( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestBody BodyTypeAnalysisRequest request + ) { + return BaseResponse.onSuccess(memberBodyTypeCommandService.analyzeBodyType(customUserDetails.getMember(), request.getAnswer())); + } + +// @GetMapping() +// @Operation(summary = "체형 질문 문항 조회 API - 프론트와 협의 필요(API 연동 안 해도 됨)", +// description = "체형 분석을 하기 위해 질문 문항을 받아 오는 API입니다. 이 부분은 서버에서 바로 프롬프트로 넣는 방법도 있기 때문에 프론트와 협의 후 진행하겠습니다.") +// @ApiResponses({ +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공") +// }) +// public BaseResponse getQuestion() { +// return BaseResponse.onSuccess(null); +// } + + @GetMapping("/result") + @Operation(summary = "체형 분석 결과 조회 API", description = "사용자의 체형 분석 결과를 받아 오는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "체형 분석 결과 조회 성공"), + @ApiResponse( + responseCode = "400", + description = "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "JSON_PARSING400", + "message": "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "체형 분석 결과를 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "MEMBER_BODY_TYPE404", + "message": "체형 분석 결과를 찾을 수 없습니다." + } + """ + ) + ) + ), + }) + public BaseResponse getBodyType(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return BaseResponse.onSuccess(memberBodyTypeQueryService.getBodyTypeAnalysis(customUserDetails.getMember())); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java b/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java new file mode 100644 index 00000000..7845d86d --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java @@ -0,0 +1,29 @@ +package com.example.mody.domain.bodytype.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +/** + * 체형 분석 요청 DTO + */ +@Getter +@Setter +@NoArgsConstructor +public class BodyTypeAnalysisRequest { + @Schema(description = "체형 분석 질문에 대한 답변", example = "목 두께가 얇고 상대적으로 긴 편이다. " + + "쇄골이 굵고 단단하며 뼈와 힘줄이 돋보인다. " + + "보송보송하고 관절과 힘줄이 부각된다. " + + "다리 길이가 상대적으로 긴 편이다. " + + "허리가 굴곡이 없는 편이다. " + + "어깨의 뼈가 크고 넓은 편이다. " + + "엉덩이가 입체적이며 탄탄하다. " + + "어깨선 및 쇄골뼈가 부각되어 있다. " + + "탄탄하고 근육이 붙어있다. " + + "팔, 가슴, 배 등 상체 위주로 살이 찐다. " + + "보통이다.") + private String answer; +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/bodytype/dto/response/BodyTypeAnalysisResponse.java b/src/main/java/com/example/mody/domain/bodytype/dto/response/BodyTypeAnalysisResponse.java new file mode 100644 index 00000000..2b697b74 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/dto/response/BodyTypeAnalysisResponse.java @@ -0,0 +1,55 @@ +package com.example.mody.domain.bodytype.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 체형 분석 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "체형 분석 결과 응답 DTO") +public class BodyTypeAnalysisResponse { + + @Schema(description = "사용자 이름", example = "차은우") + private String name; + + @Schema(description = "체형 분석 결과") + private BodyTypeAnalysis bodyTypeAnalysis; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "체형 분석 결과") + public static class BodyTypeAnalysis { + + @Schema(description = "체형 유형", example = "네추럴") + private String type; + + @Schema(description = "체형 설명", example = "차은우님의 체형은 큰 뼈대와 넓은 어깨, 굵은 쇄골, 그리고 허리의 굴곡이 뚜렷하지 않은 네추럴 유형에 해당합니다. 전체적으로 다부지고 탄탄한 신체구조를 가지고 있으며 다리와 엉덩이가 비교적 길고 입체적입니다.") + private String description; + + @Schema(description = "스타일링 제안") + private FeatureBasedSuggestions featureBasedSuggestions; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "스타일링 제안") + public static class FeatureBasedSuggestions { + + @Schema(description = "강조할 부분", example = "차은우님은 넓고 탄탄한 어깨와 긴 다리를 활용한 스타일링이 가능합니다. 어깨와 다리의 비율을 강조하는 깔끔한 실루엣의 재킷과 팬츠는 체형의 균형감을 극대화시킵니다.") + private String emphasize; + + @Schema(description = "보완할 부분", example = "허리 굴곡이 뚜렷하지 않으므로, 허리선을 살짝 강조하는 디자인이나 레이어드 스타일링으로 몸의 중간 라인을 정의하면 더욱 세련된 인상을 줄 수 있습니다.") + private String enhance; + } +} diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/Answer.java b/src/main/java/com/example/mody/domain/bodytype/entity/Answer.java new file mode 100644 index 00000000..468216f2 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/Answer.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.bodytype.entity; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +@Entity +@Getter +@Setter +@DynamicUpdate +@DynamicInsert +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Answer extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "answer_id") + private Long id; + + @Column(nullable = false, length = 100) + private String content; // 답변 내용 + + @Column(nullable = true, length = 100) // 잠시 nullable로 세팅. 이미지 구현 완료 후에 false로 변경 예정 + private String imageUrl; // 답변 이미지 S3 url + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id") + private Question question; +} diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java new file mode 100644 index 00000000..217032a5 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.bodytype.entity; + +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@DynamicUpdate +@DynamicInsert +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class BodyType extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "body_type_id") + private Long id; + + @Column(nullable = false, length = 30) + private String name; // 네추럴, 스트레이트, 웨이브 + + @OneToMany(mappedBy = "bodyType", cascade = CascadeType.ALL) + private List memberBodyTypeList = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/Question.java b/src/main/java/com/example/mody/domain/bodytype/entity/Question.java new file mode 100644 index 00000000..59bb5e28 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/Question.java @@ -0,0 +1,32 @@ +package com.example.mody.domain.bodytype.entity; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@DynamicUpdate +@DynamicInsert +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Question extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "question_id") + private Long id; + + @Column(nullable = false, length = 100) + private String content; // 질문 내용 + + @OneToMany(mappedBy = "question", cascade = CascadeType.ALL) + private List answerList = new ArrayList<>(); +} diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java new file mode 100644 index 00000000..d62817fc --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.bodytype.entity.mapping; + +import com.example.mody.domain.bodytype.entity.Answer; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +@Entity +@Getter +@Setter +@DynamicUpdate +@DynamicInsert +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class MemberAnswer extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_answer_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "answer_id") + private Answer answer; +} diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java new file mode 100644 index 00000000..7f3c0fa6 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java @@ -0,0 +1,42 @@ +package com.example.mody.domain.bodytype.entity.mapping; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +@Entity +@Getter +@Setter +@DynamicUpdate +@DynamicInsert +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class MemberBodyType extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_body_type_id") + private Long id; + + @Column(nullable = false, length = 1000) + private String body; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "body_type_id") + private BodyType bodyType; + + public MemberBodyType(String body, Member member, BodyType bodyType) { + this.body = body; + this.member = member; + this.bodyType = bodyType; + } +} diff --git a/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java b/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java new file mode 100644 index 00000000..6fd0da1a --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java @@ -0,0 +1,12 @@ +package com.example.mody.domain.bodytype.repository; + +import com.example.mody.domain.bodytype.entity.BodyType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface BodyTypeRepository extends JpaRepository { + Optional findByName(String bodyTypeName); +} diff --git a/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java new file mode 100644 index 00000000..fc6af18b --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java @@ -0,0 +1,16 @@ +package com.example.mody.domain.bodytype.repository; + +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberBodyTypeRepository extends JpaRepository { + Optional findTopByMemberOrderByCreatedAt(Member member); + Optional findMemberBodyTypeByMember(Member member); + Long countAllByMember(Member member); + Optional findTopByMemberOrderByCreatedAtDesc(Member member); +} diff --git a/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java b/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java new file mode 100644 index 00000000..3ded547d --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java @@ -0,0 +1,28 @@ +package com.example.mody.domain.bodytype.service; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.bodytype.repository.BodyTypeRepository; +import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository; +import com.example.mody.domain.member.entity.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class BodyTypeService { + private final BodyTypeRepository bodyTypeRepository; + private final MemberBodyTypeRepository memberBodyTypeRepository; + + /** + * 유저의 가장 최근 체형 분석 결과의 체형 타입을 반환하는 메서드 + * @param member 체형 타입을 조회할 유저 + * @return 마지막 체형 분석이 존재하지 않을 경우 empty Optional을 반환함. + */ + public Optional findLastBodyType(Member member){ + Optional optionalMemberBodyType = memberBodyTypeRepository.findTopByMemberOrderByCreatedAt(member); + return optionalMemberBodyType.map(MemberBodyType::getBodyType); + } +} diff --git a/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryService.java b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryService.java new file mode 100644 index 00000000..f391f5bd --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryService.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.bodytype.service.bodytype; + +import com.example.mody.domain.bodytype.entity.BodyType; + +public interface BodyTypeQueryService { + BodyType findByBodyTypeName(String bodyTypeName); +} diff --git a/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java new file mode 100644 index 00000000..b5d66e5d --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java @@ -0,0 +1,25 @@ +package com.example.mody.domain.bodytype.service.bodytype; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.repository.BodyTypeRepository; +import com.example.mody.domain.exception.BodyTypeException; +import com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class BodyTypeQueryServiceImpl implements BodyTypeQueryService { + + private final BodyTypeRepository bodyTypeRepository; + + // 체형 이름으로 BodyType 클래스 조회 + @Override + public BodyType findByBodyTypeName(String bodyTypeName) { + Optional optionalBodyType = bodyTypeRepository.findByName(bodyTypeName); + BodyType bodyType = optionalBodyType.orElseThrow(()-> new BodyTypeException(BodyTypeErrorStatus.BODY_TYPE_NOT_FOUND)); + return bodyType; + } +} diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java new file mode 100644 index 00000000..f89dc89a --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java @@ -0,0 +1,8 @@ +package com.example.mody.domain.bodytype.service.memberbodytype; + +import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; +import com.example.mody.domain.member.entity.Member; + +public interface MemberBodyTypeCommandService { + public BodyTypeAnalysisResponse analyzeBodyType(Member member, String answers); +} diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java new file mode 100644 index 00000000..8ebd2b92 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java @@ -0,0 +1,40 @@ +package com.example.mody.domain.bodytype.service.memberbodytype; + +import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository; +import com.example.mody.domain.bodytype.service.bodytype.BodyTypeQueryService; +import com.example.mody.domain.chatgpt.service.ChatGptService; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.Gender; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberBodyTypeCommandServiceImpl implements MemberBodyTypeCommandService { + + private final MemberBodyTypeRepository memberBodyTypeRepository; + private final ChatGptService chatGptService; + private final BodyTypeQueryService bodyTypeQueryService; + + @Override + public BodyTypeAnalysisResponse analyzeBodyType(Member member, String answers) { + // 사용자 정보(닉네임, 성별) 조회 + String nickname = member.getNickname(); + Gender gender = member.getGender(); + + // OpenAi API를 통한 체형 분석 + String content = chatGptService.getContent(nickname, gender, answers); + BodyTypeAnalysisResponse bodyTypeAnalysisResponse = chatGptService.analyzeBodyType(content); + + // MemberBodyType을 DB에 저장 + BodyType bodyType = bodyTypeQueryService.findByBodyTypeName(bodyTypeAnalysisResponse.getBodyTypeAnalysis().getType()); + memberBodyTypeRepository.save(new MemberBodyType(content, member, bodyType)); + + return bodyTypeAnalysisResponse; + } +} diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java new file mode 100644 index 00000000..9d394ff0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java @@ -0,0 +1,11 @@ +package com.example.mody.domain.bodytype.service.memberbodytype; + +import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.member.entity.Member; + +public interface MemberBodyTypeQueryService { + public BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member); + + public MemberBodyType getMemberBodyType(Member member); +} diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java new file mode 100644 index 00000000..d2b64cb3 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java @@ -0,0 +1,43 @@ +package com.example.mody.domain.bodytype.service.memberbodytype; + +import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository; +import com.example.mody.domain.exception.BodyTypeException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberBodyTypeQueryServiceImpl implements MemberBodyTypeQueryService { + + private final MemberBodyTypeRepository memberBodyTypeRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member) { + MemberBodyType memberBodyType = getMemberBodyType(member); + + // MemberBodyType의 body 내용을 BodyTypeAnalysisResponse로 변환 + try { + return objectMapper.readValue(memberBodyType.getBody(), BodyTypeAnalysisResponse.class); + } catch (JsonProcessingException e) { + throw new BodyTypeException(BodyTypeErrorStatus.JSON_PARSING_ERROR); + } + } + + // Member로 MemberBodyType 조회 + @Override + public MemberBodyType getMemberBodyType(Member member) { + Optional optionalMemberBodyType = memberBodyTypeRepository.findTopByMemberOrderByCreatedAtDesc(member); + return optionalMemberBodyType.orElseThrow(() -> new BodyTypeException(BodyTypeErrorStatus.MEMBER_BODY_TYPE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java new file mode 100644 index 00000000..69db1ef2 --- /dev/null +++ b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java @@ -0,0 +1,160 @@ +package com.example.mody.domain.chatgpt.service; + +import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; +import com.example.mody.domain.recommendation.dto.request.MemberInfoRequest; +import com.example.mody.domain.recommendation.dto.response.analysis.ItemAnalysisResponse; +import com.example.mody.domain.member.enums.Gender; +import com.example.mody.domain.recommendation.dto.request.RecommendRequest; +import com.example.mody.domain.recommendation.dto.response.analysis.StyleAnalysisResponse; +import com.example.mody.domain.recommendation.service.CrawlerService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AnalysisErrorStatus; +import com.example.mody.global.infrastructure.ai.OpenAiApiClient; +import com.example.mody.global.infrastructure.ai.dto.ChatGPTResponse; +import com.example.mody.global.infrastructure.ai.dto.Message; +import com.example.mody.global.templates.PromptManager; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * PromptManager를 통해 프롬프트를 생성하고, OpenAIApiClient를 사용하여 ChatGPT 모델에 요청을 보내 응답을 처리 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public final class ChatGptService { + + private final OpenAiApiClient openAiApiClient; // ChatGPT API와의 통신을 담당 + private final PromptManager promptManager; // 프롬프트 생성 + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 응답 변환 + private final CrawlerService crawlerService; + + @Value("${openai.model}") + private String model; // OpenAI 모델 + + @Value("${openai.max-tokens}") + private int maxTokens; // 최대 토큰 수 + + @Value("${openai.temperature}") + private double temperature; // 생성된 응답의 창의성 정도 + + private final String systemRole = "system"; // 대화에서의 역할(시스템 메시지) + + private final String userRole = "user"; // 대화에서의 역할(사용자 메시지) + + // OpenAi 응답에서 content 추출하는 메서드 + public String getContent(String nickName, Gender gender, String answers) { + ChatGPTResponse response = getChatGPTResponse(nickName, gender, answers); + + // response에서 content 추출 + String content = response.getChoices().get(0).getMessage().getContent().trim(); + log.info("content: {}", content); + + // 백틱과 "json" 등을 제거 + if (content.startsWith("```")) { + content = content.replaceAll("```[a-z]*", "").trim(); + } + log.info("백틱 제거 후 content: {}", content); + return content; + } + + // OpenAi 응답 메서드 + private ChatGPTResponse getChatGPTResponse(String nickName, Gender gender, String answers) { + // 템플릿 생성 + String prompt = promptManager.createBodyTypeAnalysisPrompt(nickName, gender, answers); + + // OpenAI API 호출 + ChatGPTResponse response = openAiApiClient.sendRequestToModel( + model, + List.of( + new Message(systemRole, prompt) + ), + maxTokens, + temperature + ); + return response; + } + + // 체형 분석 메서드 + public BodyTypeAnalysisResponse analyzeBodyType(String content) { + + try { + // content -> BodyTypeAnalysisResponse 객체로 변환 + return objectMapper.readValue(content, BodyTypeAnalysisResponse.class); + } catch (JsonMappingException e) { + throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); + } catch (JsonProcessingException e) { + throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); + } + } + + // 스타일 추천 메서드 + public StyleAnalysisResponse recommendGptStyle(MemberInfoRequest memberInfoRequest, RecommendRequest recommendRequest){ + + //스타일 추천 프롬프트 생성 + String prompt = promptManager.createRecommendStylePrompt(memberInfoRequest, recommendRequest); + + //openAiApiClient로 gpt 답변 생성 + ChatGPTResponse response = openAiApiClient.sendRequestToModel( + model, + List.of( + new Message(systemRole, prompt) + ), + maxTokens, + temperature); + String content = response.getChoices().get(0).getMessage().getContent().trim(); + + try{ + StyleAnalysisResponse styleAnalysisResponse = objectMapper.readValue(content, StyleAnalysisResponse.class); + return styleAnalysisResponse.from(searchImageFromPinterest(memberInfoRequest.getGender(), styleAnalysisResponse.getRecommendedStyle())); + } catch (JsonMappingException e) { + throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); + } catch (JsonProcessingException e) { + throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); + } + } + + private String searchImageFromPinterest(Gender gender, String recommendedStyle) { + String strGender = (gender == Gender.MALE) ? "남성 " : "여성 "; + String keyword = strGender + recommendedStyle; + log.info("keyword: {}", keyword); + + String imageUrl = crawlerService.getRandomImageUrl(keyword); + log.info("Pinterest 이미지 URL: {}", imageUrl); + + return imageUrl; + } + + // 패션 아이템 추천 메서드 + public ItemAnalysisResponse recommendGptItem(MemberInfoRequest memberInfoRequest, RecommendRequest recommendRequest){ + + //아이템 추천 프롬프트 생성 + String prompt = promptManager.createRecommendItemPrompt(memberInfoRequest, recommendRequest); + + //openAiApiClient로 gpt 답변 생성 + ChatGPTResponse response = openAiApiClient.sendRequestToModel( + model, + List.of( + new Message(systemRole, prompt) + ), + maxTokens, + temperature); + String content = response.getChoices().get(0).getMessage().getContent().trim(); + + try{ + ItemAnalysisResponse itemAnalysisResponse = objectMapper.readValue(content, ItemAnalysisResponse.class); + return itemAnalysisResponse.from(searchImageFromPinterest(memberInfoRequest.getGender(), itemAnalysisResponse.getItem())); + } catch (JsonMappingException e) { + throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); + } catch (JsonProcessingException e) { + throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/exception/BodyTypeException.java b/src/main/java/com/example/mody/domain/exception/BodyTypeException.java new file mode 100644 index 00000000..9c214edc --- /dev/null +++ b/src/main/java/com/example/mody/domain/exception/BodyTypeException.java @@ -0,0 +1,10 @@ +package com.example.mody.domain.exception; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +public class BodyTypeException extends RestApiException { + public BodyTypeException(BaseCodeInterface errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/mody/domain/exception/MemberException.java b/src/main/java/com/example/mody/domain/exception/MemberException.java new file mode 100644 index 00000000..ff76811e --- /dev/null +++ b/src/main/java/com/example/mody/domain/exception/MemberException.java @@ -0,0 +1,11 @@ +package com.example.mody.domain.exception; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +public class MemberException extends RestApiException { + + public MemberException(BaseCodeInterface errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/mody/domain/exception/PostException.java b/src/main/java/com/example/mody/domain/exception/PostException.java new file mode 100644 index 00000000..61b4c99d --- /dev/null +++ b/src/main/java/com/example/mody/domain/exception/PostException.java @@ -0,0 +1,10 @@ +package com.example.mody.domain.exception; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +public class PostException extends RestApiException { + public PostException(BaseCodeInterface errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java b/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java new file mode 100644 index 00000000..9a170362 --- /dev/null +++ b/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java @@ -0,0 +1,11 @@ +package com.example.mody.domain.exception; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +public class RefreshTokenException extends RestApiException { + + public RefreshTokenException(BaseCodeInterface errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/mody/domain/file/controller/FileController.java b/src/main/java/com/example/mody/domain/file/controller/FileController.java index a77c8ea3..5eb15ff2 100644 --- a/src/main/java/com/example/mody/domain/file/controller/FileController.java +++ b/src/main/java/com/example/mody/domain/file/controller/FileController.java @@ -1,41 +1,35 @@ package com.example.mody.domain.file.controller; -import com.example.mody.domain.file.dto.FileCreateResponse; +import com.example.mody.domain.file.dto.request.BackUpFileRequests; +import com.example.mody.domain.file.dto.request.FileCreateResponse; import com.example.mody.domain.file.service.FileService; import com.example.mody.global.common.base.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; @Tag(name = "File API", description = "파일 관련 API") -//@RestController +@RestController @RequiredArgsConstructor @RequestMapping("/files") public class FileController { private final FileService fileService; - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "파일 생성 API", description = "파일을 생성하는 API입니다. jpg, mp3, mp4 등 다양한 파일을 업로드할 수 있습니다.") - public BaseResponse createFile - ( - @Valid @Parameter( - content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) ) - @RequestPart("file") MultipartFile file - ) - { - return BaseResponse.onSuccess(fileService.createFile("", file)); + @PostMapping + @Operation(summary = "백업 파일 등록 API", description = "파일을 S3에 업로드 한 뒤 해당 s3uri와 파일 정보를 업로드하는 API 입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "백업 파일 등록 성공"), + @ApiResponse(responseCode = "COMMON402", description = "Validation 관련 예외 - 파일 개수 제한 초과") + }) + public BaseResponse createFile(@Valid @RequestBody BackUpFileRequests backUpFileRequests) { + fileService.saveBackupFiles(backUpFileRequests); + return BaseResponse.onSuccessCreate(null); } - @DeleteMapping - @Operation(summary = "파일 삭제 API", description = "파일을 삭제하는 API입니다. 파일 URL을 입력하면 해당 파일을 삭제합니다.") - public BaseResponse deleteFile(@RequestParam("fileUrl") String fileUrl) { - fileService.deleteFile(fileUrl); - return BaseResponse.onSuccess("파일 삭제 성공"); - } } diff --git a/src/main/java/com/example/mody/domain/file/dto/request/BackUpFileRequests.java b/src/main/java/com/example/mody/domain/file/dto/request/BackUpFileRequests.java new file mode 100644 index 00000000..a725e2de --- /dev/null +++ b/src/main/java/com/example/mody/domain/file/dto/request/BackUpFileRequests.java @@ -0,0 +1,30 @@ +package com.example.mody.domain.file.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +import static com.example.mody.domain.post.constant.PostConstant.POST_IMAGE_COUNT_LIMIT; + +@NoArgsConstructor +@Getter +public class BackUpFileRequests { + + @Schema( + description = "파일명, 파일 크기, S3 URL 목록", + example = "[{" + + "\"fileName\": \"test.jpg\"," + + "\"fileSize\": 200," + + "\"s3Url\": \"https://mody-s3-bucket.s3.ap-northeast-2.amazonaws.com/filea8500f7a-c902-4f64-b605-7bc6247b4e75\"" + + "}, {" + + "\"fileName\": \"example.png\"," + + "\"fileSize\": 150," + + "\"s3Url\": \"https://mody-s3-bucket.s3.ap-northeast-2.amazonaws.com/fileb8500f7a-c902-4f64-b605-7bc6247b4e76\"" + + "}]" + ) + @Size(max = POST_IMAGE_COUNT_LIMIT, message = "파일의 최대 개수는 {max}를 초과할 수 없습니다.") + private List files; +} diff --git a/src/main/java/com/example/mody/domain/file/dto/request/BackupFileRequest.java b/src/main/java/com/example/mody/domain/file/dto/request/BackupFileRequest.java new file mode 100644 index 00000000..cc6e1e16 --- /dev/null +++ b/src/main/java/com/example/mody/domain/file/dto/request/BackupFileRequest.java @@ -0,0 +1,35 @@ +package com.example.mody.domain.file.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +public class BackupFileRequest { + + @Schema( + description = "파일명", + example = "test.jpg" + ) + @NotBlank(message = "파일명 입력은 필수입니다.") + private String fileName; + + @Schema( + description = "파일 크기. 단위는 KB", + example = "200" + ) + @NotBlank(message = "파일 크기 입력은 필수입니다.") + @Positive(message = "파일 크기는 음수일 수 없습니다.") + private Long fileSize; + + + @Schema( + description = "s3 URI", + example = "https://mody-s3-bucket.s3.ap-northeast-2.amazonaws.com/filea8500f7a-c902-4f64-b605-7bc6247b4e75]" + ) + @NotBlank(message = "파일 주소 입력은 필수입니다.") + private String s3Url; +} diff --git a/src/main/java/com/example/mody/domain/file/dto/FileCreateResponse.java b/src/main/java/com/example/mody/domain/file/dto/request/FileCreateResponse.java similarity index 79% rename from src/main/java/com/example/mody/domain/file/dto/FileCreateResponse.java rename to src/main/java/com/example/mody/domain/file/dto/request/FileCreateResponse.java index 15b89c87..2d7ae1ba 100644 --- a/src/main/java/com/example/mody/domain/file/dto/FileCreateResponse.java +++ b/src/main/java/com/example/mody/domain/file/dto/request/FileCreateResponse.java @@ -1,4 +1,4 @@ -package com.example.mody.domain.file.dto; +package com.example.mody.domain.file.dto.request; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/example/mody/domain/file/entity/BackupFile.java b/src/main/java/com/example/mody/domain/file/entity/BackupFile.java new file mode 100644 index 00000000..37e9ff6e --- /dev/null +++ b/src/main/java/com/example/mody/domain/file/entity/BackupFile.java @@ -0,0 +1,29 @@ +package com.example.mody.domain.file.entity; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class BackupFile extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "backup_image_id") + private Long id; + + private String fileName; + private Long fileSize; + private String s3Url; + + public BackupFile(String fileName, Long fileSize, String s3Url){ + this.fileName = fileName; + this.fileSize = fileSize; + this.s3Url = s3Url; + } + +} diff --git a/src/main/java/com/example/mody/domain/file/repository/BackupFileRepository.java b/src/main/java/com/example/mody/domain/file/repository/BackupFileRepository.java new file mode 100644 index 00000000..53dfec35 --- /dev/null +++ b/src/main/java/com/example/mody/domain/file/repository/BackupFileRepository.java @@ -0,0 +1,14 @@ +package com.example.mody.domain.file.repository; + +import com.example.mody.domain.file.entity.BackupFile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BackupFileRepository extends JpaRepository { + void deleteAllByIdIn(List backupImageIds); + + void deleteAllByS3UrlIn(List s3Urls); +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/file/service/FileService.java b/src/main/java/com/example/mody/domain/file/service/FileService.java index 6ec4a01e..e09f80b6 100644 --- a/src/main/java/com/example/mody/domain/file/service/FileService.java +++ b/src/main/java/com/example/mody/domain/file/service/FileService.java @@ -1,26 +1,35 @@ package com.example.mody.domain.file.service; -import com.example.mody.domain.file.dto.FileCreateResponse; +import com.example.mody.domain.file.dto.request.BackUpFileRequests; +import com.example.mody.domain.file.dto.request.BackupFileRequest; +import com.example.mody.domain.file.dto.request.FileCreateResponse; +import com.example.mody.domain.file.entity.BackupFile; +import com.example.mody.domain.file.repository.BackupFileRepository; import com.example.mody.global.util.S3FileComponent; import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -//@Service +import java.util.List; + +@Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class FileService { - private final S3FileComponent s3FileComponent; + private final BackupFileRepository backupFileRepository; @Transactional - public FileCreateResponse createFile(String domain , final MultipartFile file) { - String imageUrl = s3FileComponent.uploadFile(domain, file); - return new FileCreateResponse(imageUrl); + public void saveBackupFiles(BackUpFileRequests backupFileRequests){ + backupFileRepository.saveAll(backupFileRequests.getFiles().stream() + .map(file -> new BackupFile(file.getFileName(), + file.getFileSize(), + file.getS3Url())) + .toList()); } - public void deleteFile(String fileUrl) { - s3FileComponent.deleteFile(fileUrl); + public void deleteByS3Urls(List s3Urls){ + backupFileRepository.deleteAllByS3UrlIn(s3Urls); } } diff --git a/src/main/java/com/example/mody/domain/image/controller/S3Controller.java b/src/main/java/com/example/mody/domain/image/controller/S3Controller.java new file mode 100644 index 00000000..bbb2da71 --- /dev/null +++ b/src/main/java/com/example/mody/domain/image/controller/S3Controller.java @@ -0,0 +1,164 @@ +package com.example.mody.domain.image.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.image.dto.request.PostPresignedUrlRequest; +import com.example.mody.domain.image.dto.request.ProfilePresignedUrlRequest; +import com.example.mody.domain.image.dto.response.PresignedUrlResponse; +import com.example.mody.domain.image.service.S3Service; +import com.example.mody.global.common.base.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "S3 API", description = "S3 사진 업로드 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/image") +public class S3Controller { + + private final S3Service s3Service; + + @PostMapping(value = "/upload/posts") + @Operation(summary = "게시글 presigned url 생성 API", description = "게시글 파일 업로드(put) presigned url을 생성하는 API 입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "S3 버킷을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_404", + "message": "지정된 S3 버킷을 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "presigned url 생성을 실패하였습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_500", + "message": "S3 presigned url 생성 중 오류가 발생했습니다." + } + """ + ) + ) + ), + }) + public BaseResponse> getPostPresignedUrl( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestBody PostPresignedUrlRequest request + ) { + List presignedUrlResponse = s3Service.getPostPresignedUrls(customUserDetails.getMember().getId(), request.getFilenames()); + return BaseResponse.onSuccess(presignedUrlResponse); + } + + @PostMapping(value = "/upload/profiles") + @Operation(summary = "프로필 사진 presigned url 생성 API", description = "프로필 사진 파일 업로드(put) presigned url을 생성하는 API 입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "S3 버킷을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_404", + "message": "지정된 S3 버킷을 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "presigned url 생성을 실패하였습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_500", + "message": "S3 presigned url 생성 중 오류가 발생했습니다." + } + """ + ) + ) + ), + }) + public BaseResponse getProfilePresignedUrl( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestBody ProfilePresignedUrlRequest request + ) { + PresignedUrlResponse presignedUrlResponse = s3Service.getProfilePresignedUrl(customUserDetails.getMember().getId(), request.getFilename()); + return BaseResponse.onSuccess(presignedUrlResponse); + } + +// // 테스트용(실제 사용 X) +// @GetMapping(value = "/getS3Url") +// @Operation(summary = "자체 S3 URL 조회 - 테스트용으로 API 연동 시 신경 안 써도 되는 API 입니다.", +// description = "프론트에서 S3에 파일 업로드 후 반환하는 S3 URL을 서버에서 생성해 확인해보는 테스트용 API 입니다.") +// @ApiResponses({ +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "S3 url 반환 성공") +// }) +// public BaseResponse getS3Url(@RequestParam String key) { +// S3UrlResponse s3UrlResponse = s3Service.getS3Url(key); +// return BaseResponse.onSuccess(s3UrlResponse); +// } + +} diff --git a/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java b/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java new file mode 100644 index 00000000..b9695ddb --- /dev/null +++ b/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java @@ -0,0 +1,19 @@ +package com.example.mody.domain.image.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +/** + * 게시글 S3 presigned url 요청 DTO + */ +@Getter +@Setter +@NoArgsConstructor +public class PostPresignedUrlRequest { + @Schema(description = "업로드할 파일 목록", example = "[\"a.png\", \"b.jpg\"]") + private List filenames; +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java b/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java new file mode 100644 index 00000000..6fd9d300 --- /dev/null +++ b/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java @@ -0,0 +1,19 @@ +package com.example.mody.domain.image.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +/** + * 프로필 사진 S3 presigned url 요청 DTO + */ +@Getter +@Setter +@NoArgsConstructor +public class ProfilePresignedUrlRequest { + @Schema(description = "업로드할 파일", example = "a.png") + private String filename; +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java b/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java new file mode 100644 index 00000000..2fdf9219 --- /dev/null +++ b/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java @@ -0,0 +1,27 @@ +package com.example.mody.domain.image.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +/** + * S3 presigned url 응답 DTO + */ +@Getter +@Setter +@NoArgsConstructor +@EqualsAndHashCode +@Schema(description = "S3 presigned url 응답 DTO") +public class PresignedUrlResponse { + + @Schema(description = "S3 presigned url", example = "https://{bucket-name}.s3.{region}.amazonaws.com/{object-key}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date={timestamp}&X-Amz-SignedHeaders=host&X-Amz-Expires={expires}&X-Amz-Credential={credential}&X-Amz-Signature={signature}") + private String presignedUrl; + @Schema(description = "S3 key 값", example = "{folder}/{memberId}/{uuid}/{filename}") + private String key; + + public static PresignedUrlResponse of(String preSignedUrl, String key){ + PresignedUrlResponse response = new PresignedUrlResponse(); + response.setPresignedUrl(preSignedUrl); + response.setKey(key); + return response; + } +} diff --git a/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java b/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java new file mode 100644 index 00000000..9f30d03c --- /dev/null +++ b/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java @@ -0,0 +1,27 @@ +package com.example.mody.domain.image.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * S3 url 응답 DTO + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "S3 url 응답 DTO") +public class S3UrlResponse { + + @Schema(description = "S3 url", example = "https://{bucket-name}.s3.{region}.amazonaws.com/{object-key}") + private String s3Url; + + public static S3UrlResponse from(String s3Url){ + S3UrlResponse response = new S3UrlResponse(); + response.setS3Url(s3Url); + return response; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/image/service/S3Service.java b/src/main/java/com/example/mody/domain/image/service/S3Service.java new file mode 100644 index 00000000..11cc3f7f --- /dev/null +++ b/src/main/java/com/example/mody/domain/image/service/S3Service.java @@ -0,0 +1,124 @@ +package com.example.mody.domain.image.service; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.Headers; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.example.mody.domain.image.dto.response.PresignedUrlResponse; +import com.example.mody.domain.image.dto.response.S3UrlResponse; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.S3ErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URL; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class S3Service { + private final AmazonS3 amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.region.static}") + private String region; + + public List getPostPresignedUrls(Long memberId, List filenames) { + // 게시물당 하나의 UUID를 생성 + String uuid = UUID.randomUUID().toString(); + + List presignedUrls = new ArrayList<>(); + for (String filename : filenames) { + String sanitizedFilename = filename.replaceAll("[^/]+://[^/]+/", ""); + String key = String.format("post/%d/%s/%s", memberId, uuid, sanitizedFilename); + Date expiration = getExpiration(); // 유효 기간 + GeneratePresignedUrlRequest generatePresignedUrlRequest = generatePresignedUrl(key, expiration); + + URL url = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest); + presignedUrls.add(PresignedUrlResponse.of(url.toExternalForm(), key)); + } + return presignedUrls; + } + + public PresignedUrlResponse getProfilePresignedUrl(Long memberId, String filename) { + // key값 설정(profile 경로 + 멤버ID + 랜덤 값 + filename) + String sanitizedFilename = filename.replaceAll("[^/]+://[^/]+/", ""); + String key = String.format("profile/%d/%s/%s", memberId, UUID.randomUUID(), sanitizedFilename); + + // 유효 기간 + Date expiration = getExpiration(); + + // presigned url 생성 + GeneratePresignedUrlRequest generatePresignedUrlRequest = generatePresignedUrl(key, expiration); + URL url = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest); + + return PresignedUrlResponse.of(url.toExternalForm(), key); + } + + // 업로드용(put) presigned url 생성하는 메서드 + private GeneratePresignedUrlRequest generatePresignedUrl(String key, Date expiration) { + try { + GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, key) + .withMethod(HttpMethod.PUT) + .withKey(key) + .withExpiration(expiration); + generatePresignedUrlRequest.addRequestParameter( + Headers.S3_CANNED_ACL, + CannedAccessControlList.PublicRead.toString()); + return generatePresignedUrlRequest; + } catch (AmazonS3Exception e) { + throw new RestApiException(S3ErrorStatus.BUCKET_NOT_FOUND); + } catch (Exception e) { + throw new RestApiException(S3ErrorStatus.PRESIGNED_URL_GENERATION_FAILED); + } + } + + // 유효 기간 설정 + private static Date getExpiration() { + Date expiration = new Date(); + long expTimeMillis = expiration.getTime(); + expTimeMillis += 2 * 1000 * 60 * 60; // 2시간 설정 + expiration.setTime(expTimeMillis); + return expiration; + } + + // S3 이미지 삭제 + public void deleteFile(List postImageUrls) { // 파일 삭제 실패해도 다음 파일 삭제를 수행하도록 예외를 터뜨리는 것이 아닌 로그만 찍음 + for (String imageUrl : postImageUrls) { + try { + String key = extractKey(imageUrl); + amazonS3Client.deleteObject(bucket, key); + log.info("S3 파일 삭제 성공: {}", key); + } catch (AmazonServiceException e) { + log.error("S3 파일 삭제 실패 - AWS 서비스 오류: {}, Key: {}", e.getErrorMessage(), imageUrl, e); + } catch (Exception e) { + log.error("S3 파일 삭제 중 알 수 없는 오류 발생: {}, Key: {}", imageUrl, e.getMessage(), e); + } + } + } + + // S3 url에서 key 값 추출 + private String extractKey(String imageUrl) { + return imageUrl.substring(imageUrl.indexOf(".com/") + 5); // 해당 index + 5 값이 key 값의 시작 인덱스 값 + } + +// // 프론트에서 전달받은 key를 이용해 S3 URL 생성 및 반환(테스트용) +// public S3UrlResponse getS3Url(String key) { +// String s3Url = String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, region, key); +// log.info("s3Url: {}", s3Url); +// return S3UrlResponse.from(s3Url); +// } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/member/controller/MemberController.java b/src/main/java/com/example/mody/domain/member/controller/MemberController.java new file mode 100644 index 00000000..72421d1f --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/controller/MemberController.java @@ -0,0 +1,48 @@ +package com.example.mody.domain.member.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.member.dto.response.MemberProfileResponse; +import com.example.mody.domain.member.service.MemberCommandService; +import com.example.mody.domain.member.service.MemberQueryService; +import com.example.mody.global.common.base.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/members") +public class MemberController { + + private final MemberQueryService memberQueryService; + private final MemberCommandService memberCommandService; + + @GetMapping("/me") + @Operation(summary = "자신의 프로필 조회 API", description = "요청 클라이언트의 정보를 반환하는 API") + @ApiResponses({ + @ApiResponse(responseCode = "COMMON200", description = "프로필 조회 성공"), + }) + public BaseResponse getMyProfile( + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + MemberProfileResponse response = memberQueryService.getMyProfile(customUserDetails.getMember()); + return BaseResponse.onSuccess(response); + } + + /** + * 회원탈퇴 API (soft delete) + */ + @PostMapping("/withdraw") + public BaseResponse withdraw(@AuthenticationPrincipal CustomUserDetails userDetails) { + // 현재 로그인된 회원의 id로 탈퇴 처리 + memberCommandService.withdrawMember(userDetails.getMember().getId()); + return BaseResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/example/mody/domain/member/dto/response/MemberProfileResponse.java b/src/main/java/com/example/mody/domain/member/dto/response/MemberProfileResponse.java new file mode 100644 index 00000000..bfd0d6e8 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/dto/response/MemberProfileResponse.java @@ -0,0 +1,57 @@ +package com.example.mody.domain.member.dto.response; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.Gender; +import com.example.mody.domain.member.enums.LoginType; +import com.example.mody.domain.member.enums.Role; +import com.example.mody.domain.member.enums.Status; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.Optional; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberProfileResponse { + private final Long id; + private final String email; + private final String bodyType; + private final String provider; + private final String nickname; + private final Integer likeCount; + private final Long inspectedBodyTypeCount; + private final String profileImageUrl; + private final LocalDate birthDate; + private final Gender gender; + private final Integer height; + private final Status status; + private final Role role; + private final LoginType loginType; + + public static MemberProfileResponse of(Member member, Optional bodyType, Long inspectedBodyTypeCount){ + return MemberProfileResponse.builder() + .id(member.getId()) + .email(member.getEmail()) + .bodyType(bodyType + .map(BodyType::getName) + .orElse(null)) + .provider(member.getProvider()) + .nickname(member.getNickname()) + .likeCount(member.getLikeCount()) + .inspectedBodyTypeCount(inspectedBodyTypeCount) + .profileImageUrl(member.getProfileImageUrl()) + .birthDate(member.getBirthDate()) + .gender(member.getGender()) + .height(member.getHeight()) + .status(member.getStatus()) + .role(member.getRole()) + .loginType(member.getLoginType()) + .build(); + } + +} diff --git a/src/main/java/com/example/mody/domain/member/entity/Member.java b/src/main/java/com/example/mody/domain/member/entity/Member.java new file mode 100644 index 00000000..841d779e --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -0,0 +1,177 @@ +package com.example.mody.domain.member.entity; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import com.example.mody.domain.recommendation.entity.Recommendation; +import com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.member.enums.Gender; +import com.example.mody.domain.member.enums.LoginType; +import com.example.mody.domain.member.enums.Role; +import com.example.mody.domain.member.enums.Status; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.domain.post.entity.mapping.MemberPostLike; +import com.example.mody.global.common.base.BaseEntity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "member") +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @OneToMany(mappedBy = "member", + cascade = CascadeType.ALL, + orphanRemoval = true) + private List likes = new ArrayList<>(); + + @OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL) + private List memberBodyType = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List recommendations = new ArrayList<>(); + + @OneToMany(mappedBy = "member", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List RecommendLikes = new ArrayList<>(); + + /** + * 회원이 작성한 게시글 목록 + * - 회원이 삭제되면 함께 삭제됩니다. + * - 회원이 작성한 게시글을 조회할 때 사용합니다. + */ + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List posts = new ArrayList<>(); + + private String providerId; // OAuth2 제공자의 고유 ID + + private String provider; // OAuth2 제공자 (kakao, google 등) + + @Column(unique = true) + private String email; + + private String nickname; + + private String password; + + private String profileImageUrl; + + private LocalDate birthDate; + + @Builder.Default + @Column(nullable = false) + private Integer likeCount = 0; + + @Enumerated(EnumType.STRING) + private Gender gender; + + private Integer height; + + @Enumerated(EnumType.STRING) + private Status status; + + @Column(nullable = false) + @Builder.Default + private Integer reportCount = 0; + + @Enumerated(EnumType.STRING) + private Role role; + + @Enumerated(EnumType.STRING) + private LoginType loginType; + + @Builder.Default + private boolean isRegistrationCompleted = false; // 회원가입 완료 여부 + + public void completeRegistration(String nickname, LocalDate birthDate, Gender gender, Integer height + , String profileImageUrl) { + this.nickname = nickname; + this.birthDate = birthDate; + this.gender = gender; + this.height = height; + this.profileImageUrl = profileImageUrl; + this.isRegistrationCompleted = true; + } + + public void setEncodedPassword(String password) { + this.password = password; + } + + public void increaseReportCount() { + this.reportCount++; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + this.likeCount--; + } + + /** + * 게시글 삭제 등으로 좋아요 수를 줄이는 경우 + * @param count + */ + public void decreaseLikeCount(int count) { + this.likeCount -= count; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Member that)) { + return false; + } + return Objects.equals(that.getId(), this.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + /** + * 회원탈퇴(soft delete) 처리 메서드. + * 상태를 DELETED로 변경하고, 삭제 시점을 기록합니다. + */ + public void softDelete() { + this.status = Status.DELETED; + this.delete(); // BaseEntity의 delete() 메서드가 deletedAt을 현재 시간으로 설정합니다. + } +} diff --git a/src/main/java/com/example/mody/domain/member/enums/Gender.java b/src/main/java/com/example/mody/domain/member/enums/Gender.java new file mode 100644 index 00000000..708961b8 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/enums/Gender.java @@ -0,0 +1,12 @@ +package com.example.mody.domain.member.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "성별") +public enum Gender { + @Schema(description = "남성") + MALE, + + @Schema(description = "여성") + FEMALE +} diff --git a/src/main/java/com/example/mody/domain/member/enums/LoginType.java b/src/main/java/com/example/mody/domain/member/enums/LoginType.java new file mode 100644 index 00000000..d5e0d847 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/enums/LoginType.java @@ -0,0 +1,5 @@ +package com.example.mody.domain.member.enums; + +public enum LoginType { + GENERAL, KAKAO +} diff --git a/src/main/java/com/example/mody/domain/member/enums/Role.java b/src/main/java/com/example/mody/domain/member/enums/Role.java new file mode 100644 index 00000000..03e57d48 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/enums/Role.java @@ -0,0 +1,5 @@ +package com.example.mody.domain.member.enums; + +public enum Role { + ROLE_USER, ROLE_ADMIN +} diff --git a/src/main/java/com/example/mody/domain/member/enums/Status.java b/src/main/java/com/example/mody/domain/member/enums/Status.java new file mode 100644 index 00000000..8197bb51 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/enums/Status.java @@ -0,0 +1,17 @@ +package com.example.mody.domain.member.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Status { + + ACTIVE("활성화"), + INACTIVE("비활성화"), + DELETED("삭제") + ; + + private final String description; + +} diff --git a/src/main/java/com/example/mody/domain/member/exception/annotation/ExistsMember.java b/src/main/java/com/example/mody/domain/member/exception/annotation/ExistsMember.java new file mode 100644 index 00000000..d181a25a --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/exception/annotation/ExistsMember.java @@ -0,0 +1,25 @@ +package com.example.mody.domain.member.exception.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.mody.domain.member.exception.validator.ExistsMemberValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ExistsMemberValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistsMember { + + String message() default "회원이 존재하지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/mody/domain/member/exception/validator/ExistsMemberValidator.java b/src/main/java/com/example/mody/domain/member/exception/validator/ExistsMemberValidator.java new file mode 100644 index 00000000..7525fc0f --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/exception/validator/ExistsMemberValidator.java @@ -0,0 +1,39 @@ +package com.example.mody.domain.member.exception.validator; + +import org.springframework.stereotype.Component; + +import com.example.mody.domain.member.exception.annotation.ExistsMember; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ExistsMemberValidator implements ConstraintValidator { + + private final MemberRepository memberRepository; + + @Override + public void initialize(ExistsMember constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Long memberId, ConstraintValidatorContext context) { + + if (memberId == null || !memberRepository.existsById(memberId)) { + + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate( + MemberErrorStatus.MEMBER_NOT_FOUND.getMessage() + ).addConstraintViolation(); + + return false; + } else { + return true; + } + } +} diff --git a/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java new file mode 100644 index 00000000..f66e73f4 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java @@ -0,0 +1,29 @@ +package com.example.mody.domain.member.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.LoginType; +import com.example.mody.domain.member.enums.Status; + +@Repository +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + + Boolean existsByEmail(String email); + + Optional findByProviderId(String providerId); + + Optional findByEmailAndLoginType(String email, LoginType loginType); + + Optional findByProviderIdAndLoginType(String providerId, LoginType loginType); + + Boolean existsByEmailAndLoginType(String email, LoginType loginType); + + List findByStatusAndDeletedAtBefore(Status status, LocalDateTime deletedAt); +} diff --git a/src/main/java/com/example/mody/domain/member/scheduler/MemberPurgeScheduler.java b/src/main/java/com/example/mody/domain/member/scheduler/MemberPurgeScheduler.java new file mode 100644 index 00000000..cae1b444 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/scheduler/MemberPurgeScheduler.java @@ -0,0 +1,35 @@ +package com.example.mody.domain.member.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.Status; +import com.example.mody.domain.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MemberPurgeScheduler { + + private final MemberRepository memberRepository; + + // 매일 자정에 실행 (cron 표현식은 환경에 맞게 수정하세요) + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void purgeDeletedMembers() { + LocalDateTime deleteAt = LocalDateTime.now().minusMonths(1); + List membersToPurge = memberRepository.findByStatusAndDeletedAtBefore(Status.DELETED, deleteAt); + if (!membersToPurge.isEmpty()) { + log.info("Purging {} member(s) deleted before {}", membersToPurge.size(), deleteAt); + memberRepository.deleteAll(membersToPurge); + } + } +} diff --git a/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java new file mode 100644 index 00000000..a417aea4 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java @@ -0,0 +1,15 @@ +package com.example.mody.domain.member.service; + +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; +import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; +import com.example.mody.domain.auth.dto.response.LoginResponse; + +import jakarta.servlet.http.HttpServletResponse; + +public interface MemberCommandService { + void completeRegistration(Long memberId, MemberRegistrationRequest request); + + LoginResponse joinMember(MemberJoinRequest request, HttpServletResponse response); + + void withdrawMember(Long id); +} diff --git a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java new file mode 100644 index 00000000..b68fce8c --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java @@ -0,0 +1,89 @@ +package com.example.mody.domain.member.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; +import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; +import com.example.mody.domain.auth.dto.response.LoginResponse; +import com.example.mody.domain.auth.service.AuthCommandService; +import com.example.mody.domain.exception.MemberException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.LoginType; +import com.example.mody.domain.member.enums.Role; +import com.example.mody.domain.member.enums.Status; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberCommandServiceImpl implements MemberCommandService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final AuthCommandService authCommandService; + + @Override + public void completeRegistration(Long memberId, MemberRegistrationRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + member.completeRegistration( + request.getNickname(), + request.getBirthDate(), + request.getGender(), + request.getHeight(), + request.getProfileImageUrl() + ); + } + + //회원가입 + @Override + public LoginResponse joinMember(MemberJoinRequest request, HttpServletResponse response) { + String email = request.getEmail(); + Boolean isExist = memberRepository.existsByEmail(email); + + //이미 존재하는 회원인 경우 예외처리 + if (isExist) { + throw new MemberException(MemberErrorStatus.EMAIL_ALREADY_EXISTS); + } + + //회원 저장 + Member newMember = Member.builder() + .email(email) + .status(Status.ACTIVE) + .reportCount(0) + .role(Role.ROLE_USER) + .loginType(LoginType.GENERAL) + .isRegistrationCompleted(false) + .build(); + + newMember.setEncodedPassword(passwordEncoder.encode(request.getPassword())); + memberRepository.save(newMember); + + //자동 로그인 처리 + String newAccessToken = authCommandService.processLoginSuccess(newMember, response); + + return LoginResponse.of( + newMember.getId(), + newMember.getNickname(), + true, + newMember.isRegistrationCompleted(), + newAccessToken); + } + + @Override + public void withdrawMember(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + // 회원의 상태를 '삭제'로 변경 (soft delete) + member.softDelete(); + // 별도의 memberRepository.save(member) 호출은 @Transactional 및 변경 감지로 반영됩니다. + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/member/service/MemberQueryService.java b/src/main/java/com/example/mody/domain/member/service/MemberQueryService.java new file mode 100644 index 00000000..982a27e1 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberQueryService.java @@ -0,0 +1,9 @@ +package com.example.mody.domain.member.service; + +import com.example.mody.domain.member.dto.response.MemberProfileResponse; +import com.example.mody.domain.member.entity.Member; + +public interface MemberQueryService { + Member findMemberById(Long memberId); + MemberProfileResponse getMyProfile(Member member); +} diff --git a/src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java b/src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java new file mode 100644 index 00000000..590096b0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java @@ -0,0 +1,37 @@ +package com.example.mody.domain.member.service; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository; +import com.example.mody.domain.bodytype.service.BodyTypeService; +import com.example.mody.domain.exception.MemberException; +import com.example.mody.domain.member.dto.response.MemberProfileResponse; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class MemberQueryServiceImpl implements MemberQueryService{ + private final BodyTypeService bodyTypeService; + + private final MemberRepository memberRepository; + private final MemberBodyTypeRepository memberBodyTypeRepository; + + @Override + public Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + } + + @Override + public MemberProfileResponse getMyProfile(Member member) { + Optional bodyType = bodyTypeService.findLastBodyType(member); + Long bodyTypeCount = memberBodyTypeRepository.countAllByMember(member); + return MemberProfileResponse.of(member, bodyType, bodyTypeCount); + } + +} diff --git a/src/main/java/com/example/mody/domain/post/constant/PostConstant.java b/src/main/java/com/example/mody/domain/post/constant/PostConstant.java new file mode 100644 index 00000000..0f40c574 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/constant/PostConstant.java @@ -0,0 +1,10 @@ +package com.example.mody.domain.post.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PostConstant { + public static final int POST_CONTENT_LIMIT = 1000; + public static final int POST_IMAGE_COUNT_LIMIT = 10; +} diff --git a/src/main/java/com/example/mody/domain/post/controller/PostController.java b/src/main/java/com/example/mody/domain/post/controller/PostController.java new file mode 100644 index 00000000..db65263d --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/controller/PostController.java @@ -0,0 +1,480 @@ +package com.example.mody.domain.post.controller; + + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.domain.post.dto.request.PostUpdateRequest; +import com.example.mody.domain.post.dto.response.PostResponse; +import com.example.mody.domain.post.entity.Post; +import io.swagger.v3.oas.annotations.Parameters; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +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.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.domain.post.dto.request.PostCreateRequest; +import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.exception.annotation.ExistsPost; +import com.example.mody.domain.post.service.PostCommandService; +import com.example.mody.domain.post.service.PostQueryService; +import com.example.mody.global.common.base.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import org.springframework.web.bind.annotation.*; + +@Tag(name = "POST API", description = "게시글 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts") +@Validated +public class PostController { + + private final PostCommandService postCommandService; + private final PostQueryService postQueryService; + private final MemberRepository memberRepository; + + @GetMapping + @Operation(summary = "게시글 목록 조회 API", description = "전체 게시글에 대한 목록 조회 API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 목록 조회 성공" + ) + }) + public BaseResponse getAllPosts( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "cursor", required = false) Long cursor, + @RequestParam(name = "size", defaultValue = "15") Integer size) { + PostListResponse postListResponse = postQueryService.getPosts(customUserDetails.getMember(), size, cursor); + return BaseResponse.onSuccess(postListResponse); + } + + /** + * + * @param request + * @param customUserDetails + * @return + */ + @PostMapping + @Operation(summary = "게시글 작성 API", description = "인증된 유저의 게시글 작성 API") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "게시글 작성 성공"), + @ApiResponse(responseCode = "MEMBER_BODY_TYPE404", description = "게시글을 작성하려는 유저가 아직 체형 분석을 진행하지 않은 경우"), + @ApiResponse(responseCode = "COMMON402", description = "Validation 관련 예외 - 파일 개수 제한 초과, content 길이 제한 초과"), + @ApiResponse( + responseCode = "400", + description = "S3 url 목록은 비어있을 수 없습니다. 파일을 선택하거나, presigned url 생성 api를 재요청해주세요.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON402", + "message": "Validation Error입니다.", + "result": { + "s3Urls": "S3 url 목록은 비어 있을 수 없습니다." + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "올바르지 않은 S3 url입니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_404", + "message": "요청한 S3 객체를 찾을 수 없습니다." + } + """ + ) + ) + ), + }) + public BaseResponse registerPost( + @Valid @RequestBody PostCreateRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + postCommandService.createPost(request, customUserDetails.getMember()); + return BaseResponse.onSuccessCreate(null); + } + + @DeleteMapping("/{postId}") + @Operation(summary = "게시글 삭제 API", description = "인증된 유저의 게시글 삭제 API") + @ApiResponses({ + @ApiResponse(responseCode = "COMMON202", description = "게시글 삭제 성공"), + @ApiResponse( + responseCode = "COMMON401", + description = "로그인이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T21:23:51.4515304", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "POST403", + description = "작성자가 아닌 유저의 요청으로 권한이 없는 경우", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-27T20:56:55.7942672", + "code": "POST403", + "message": "해당 게시글에 대한 권한이 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "POST404", + description = "해당 게시물을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "isSuccess": "false", + "code": "POST404", + "message": "해당 게시물을 찾을 수 없습니다." + } + """ + ) + ) + ) + }) + @Parameters({ + @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다") + }) + public BaseResponse deletePost( + @PathVariable(name = "postId") Long postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + postCommandService.deletePost(postId, customUserDetails.getMember()); + return BaseResponse.onSuccessDelete(null); + } + + @Operation( + summary = "게시글 좋아요 API", + description = "특정 게시글에 좋아요를 등록합니다. 이미 좋아요가 눌러져 있다면 좋아요가 취소됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "좋아요 처리 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-14T10:00:00", + "code": "COMMON200", + "message": "요청에 성공하였습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-14T10:00:00", + "code": "COMMON400", + "message": "필수 정보가 누락되었습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "게시글 또는 사용자를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-14T10:00:00", + "code": "POST404", + "message": "해당 게시글을 찾을 수 없습니다.", + "result": null + } + """ + ) + ) + ) + }) + @PostMapping("/{postId}/like") + public BaseResponse togglePostLike( + @PathVariable("postId") + @Parameter( + description = "좋아요를 누를 게시글 ID", + required = true, + example = "1" + ) @ExistsPost Long postId, + @AuthenticationPrincipal + @Parameter( + description = "인증된 사용자 정보", + hidden = true + ) CustomUserDetails customUserDetails + ) { + Long myId = customUserDetails.getMember().getId(); + postCommandService.togglePostLike(myId, postId); + return BaseResponse.onSuccess(null); + } + + @GetMapping("/liked") + @Operation(summary = "좋아요 누른 목록 조회 API", description = "좋아요 누른 게시글에 대한 목록 조회 API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 목록 조회 성공" + ) + }) + public BaseResponse getLikedPosts( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "cursor", required = false) Long cursor, + @RequestParam(name = "size", defaultValue = "15") Integer size) { + PostListResponse postListResponse = postQueryService.getLikedPosts(customUserDetails.getMember(), size, cursor); + return BaseResponse.onSuccess(postListResponse); + } + + @GetMapping("/me") + @Operation(summary = "내가 작성한 게시글 목록 조회 API", description = "내가 작성한 게시글에 대한 목록 조회 API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 목록 조회 성공" + ) + }) + public BaseResponse getMyPosts( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "cursor", required = false) Long cursor, + @RequestParam(name = "size", defaultValue = "15") Integer size) { + PostListResponse postListResponse = postQueryService.getMyPosts(customUserDetails.getMember(), size, cursor); + return BaseResponse.onSuccess(postListResponse); + } + + @PostMapping("/{postId}/reports") + @Operation(summary = "게시글 신고 API", description = "인증된 유저의 게시글 신고 API") + @ApiResponses({ + @ApiResponse(responseCode = "COMMON200", description = "게시글 신고 성공"), + @ApiResponse( + responseCode = "COMMON401", + description = "로그인이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T21:23:51.4515304", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "POST404", + description = "해당 게시물을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "isSuccess": "false", + "code": "POST404", + "message": "해당 게시물을 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "POST409", + description = "이미 신고한 게시물 입니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "isSuccess": "false", + "code": "POST409", + "message": "이미 신고한 게시물 입니다." + } + """ + ) + ) + ) + }) + @Parameters({ + @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다") + }) + public BaseResponse reportPost( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @PathVariable Long postId) { + postCommandService.reportPost(customUserDetails.getMember(), postId); + + return BaseResponse.onSuccess(null); + } + + @GetMapping("/{postId}") + @Operation(summary = "특정 게시글 조회 API", description = "특정 게시글 조회 API") + @ApiResponses({ + @ApiResponse(responseCode = "COMMON200", description = "게시글 조회 성공"), + @ApiResponse( + responseCode = "COMMON401", + description = "로그인을 하지 않았을 경우", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T21:23:51.4515304", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "POST404", + description = "해당 게시물을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "isSuccess": "false", + "code": "POST404", + "message": "해당 게시물을 찾을 수 없습니다." + } + """ + ) + ) + ) + }) + @Parameters({ + @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다") + }) + public BaseResponse getPost( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @PathVariable Long postId) { + PostResponse postResponse=postQueryService.getPost(customUserDetails.getMember(), postId); + + return BaseResponse.onSuccess(postResponse); + } + + + @PatchMapping("/{postId}") + @Operation(summary = "게시글 수정 API", description = "인증된 유저의 게시글 수정 API.\ncontent만 수정하더라도 항상 isPublic의 수정 정보까지 함께 받아오므로 이 점 주의해서 request body 작성해주시면 감사하겠습니다") + @ApiResponses({ + @ApiResponse(responseCode = "COMMON200", description = "게시글 수정 성공"), + @ApiResponse( + responseCode = "COMMON401", + description = "로그인을 하지 않았을 경우", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T21:23:51.4515304", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "POST403", + description = "작성자가 아닌 유저의 요청으로 권한이 없는 경우", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-27T20:56:55.7942672", + "code": "POST403", + "message": "해당 게시글에 대한 권한이 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "POST404", + description = "해당 게시물을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "isSuccess": "false", + "code": "POST404", + "message": "해당 게시물을 찾을 수 없습니다." + } + """ + ) + ) + ) + }) + @Parameters({ + @Parameter(name = "postId", description = "게시글 아이디, path variable 입니다") + }) + public BaseResponse updatePost( + @Valid @RequestBody PostUpdateRequest request, + @PathVariable(name = "postId") Long postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + postCommandService.updatePost(request, postId, customUserDetails.getMember()); + + return BaseResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java b/src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java new file mode 100644 index 00000000..bfbd7c5f --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java @@ -0,0 +1,47 @@ +package com.example.mody.domain.post.dto.request; + +import static com.example.mody.domain.post.constant.PostConstant.*; + +import java.util.List; + +import jakarta.validation.constraints.NotEmpty; +import org.hibernate.validator.constraints.Length; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Schema(description = "게시글 작성 DTO") +@Getter +@NoArgsConstructor +public class PostCreateRequest { + + @Schema( + description = "내용", + example = "스트레이트형 착장 예시" + ) + @NotBlank(message = "content 는 필수입니다.") + @Length(max = POST_CONTENT_LIMIT, message = "메세지의 최대 길이 {max}를 초과했습니다.") + private String content; + + @Schema( + description = "공개여부", + example = "true" + ) + @NotNull(message = "공개여부 입력은 필수입니다.") + private Boolean isPublic; + + @Schema( + description = "S3 url 목록", + example = "[\"https://{bucket-name}.s3.{region}.amazonaws.com/{object-key1}\", " + + "\"https://my-bucket.s3.amazonaws.com/path/to/file.jpg\"]" + ) + @NotEmpty(message = "S3 url 목록은 비어 있을 수 없습니다.") + @Size(max = POST_IMAGE_COUNT_LIMIT, message = "파일의 최대 개수는 {max}를 초과할 수 없습니다.") + private List s3Urls; + +} diff --git a/src/main/java/com/example/mody/domain/post/dto/request/PostUpdateRequest.java b/src/main/java/com/example/mody/domain/post/dto/request/PostUpdateRequest.java new file mode 100644 index 00000000..271cad90 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/dto/request/PostUpdateRequest.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.post.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +import static com.example.mody.domain.post.constant.PostConstant.POST_CONTENT_LIMIT; + +@Schema(description = "게시글 수정 DTO") +@Getter +@NoArgsConstructor +public class PostUpdateRequest { + + @Schema( + description = "내용", + example = "스트레이트형 착장 예시" + ) + @NotBlank(message = "content 는 필수입니다.") + @Length(max = POST_CONTENT_LIMIT, message = "메세지의 최대 길이 {max}를 초과했습니다.") + private String content; + + + @Schema( + description = "공개여부", + example = "true" + ) + @NotNull(message = "공개여부 입력은 필수입니다.") + private Boolean isPublic; + +} diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java new file mode 100644 index 00000000..4821d2b9 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java @@ -0,0 +1,15 @@ +package com.example.mody.domain.post.dto.response; + +import com.example.mody.domain.post.entity.PostImage; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PostImageResponse { + private String s3Url; + public static PostImageResponse of(PostImage postImage){ + return new PostImageResponse(postImage.getUrl()); + } +} diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java new file mode 100644 index 00000000..7186a315 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java @@ -0,0 +1,32 @@ +package com.example.mody.domain.post.dto.response; + +import com.example.mody.global.dto.response.CursorPagination; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PostListResponse { + private List postResponses; + private CursorPagination cursorPagination; + + public static PostListResponse of(Boolean hasNext, List postResponses){ + Long cursor = hasNext ? postResponses.getLast().getPostId() : null; + return new PostListResponse(postResponses, new CursorPagination(hasNext, cursor)); + } + + /** + * 내가 좋아요 누른 게시글 조회 시 커서를 좋아요 id로 사용함. 따라서 아래와 같이 메서드를 오버로드하여 따로 사용 + * @param hasNext 다음 페이지 유무 + * @param postResponses 반환할 데이터 + * @param cursor 반환하는 게시물 중 마지막 게시물과 클라이언트에 대한 likeId + * @return + */ + public static PostListResponse of(Boolean hasNext, List postResponses, Long cursor){ + Long newCursor = hasNext ? cursor : null; + return new PostListResponse(postResponses, new CursorPagination(hasNext, newCursor)); + } +} diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java new file mode 100644 index 00000000..e3b3baba --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java @@ -0,0 +1,44 @@ +package com.example.mody.domain.post.dto.response; + +import com.example.mody.domain.post.entity.PostImage; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class PostResponse { + + private Long postId; + private Long writerId; + private String writerNickName; + private String content; + private Boolean isPublic; + private Integer likeCount; + private Boolean isLiked; + private String bodyType; + private List files; + + public PostResponse(Long postId, + Long writerId, + String nickName, + String content, + Boolean isPublic, + Integer likeCount, + Boolean isLiked, + String bodyType, + List files) { + this.postId = postId; + this.writerId = writerId; + this.writerNickName = nickName; + this.content = content; + this.isPublic = isPublic; + this.likeCount = likeCount; + this.isLiked = isLiked; + this.bodyType = bodyType; + this.files = files.stream() + .map(PostImageResponse::of) + .toList(); + } +} diff --git a/src/main/java/com/example/mody/domain/post/dto/response/recode/LikedPostsResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/recode/LikedPostsResponse.java new file mode 100644 index 00000000..a3b39e55 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/dto/response/recode/LikedPostsResponse.java @@ -0,0 +1,8 @@ +package com.example.mody.domain.post.dto.response.recode; + +import com.example.mody.domain.post.dto.response.PostResponse; + +import java.util.List; + +public record LikedPostsResponse(Boolean hasNext, List postResponses) { +} diff --git a/src/main/java/com/example/mody/domain/post/entity/Post.java b/src/main/java/com/example/mody/domain/post/entity/Post.java new file mode 100644 index 00000000..c12ab354 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/entity/Post.java @@ -0,0 +1,99 @@ +package com.example.mody.domain.post.entity; + +import static com.example.mody.domain.post.constant.PostConstant.*; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicUpdate; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.entity.mapping.MemberPostLike; +import com.example.mody.global.common.base.BaseEntity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity(name = "post") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "post") +@DynamicUpdate +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "post_id") + private Long id; + + @OneToMany(mappedBy = "post", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List images = new ArrayList<>(); + + @OneToMany(mappedBy = "post", + cascade = CascadeType.ALL, + orphanRemoval = true) + private List likes = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "body_type_id", nullable = false) + private BodyType bodyType; + + @Column(length = POST_CONTENT_LIMIT) + private String content; + + @Column(nullable = false) + private Integer likeCount; + + @Column(nullable = false) + private Boolean isPublic; + + @Column(nullable = false) + private Integer reportCount; + + public Post(Member member, BodyType bodyType, String content, Boolean isPublic) { + this.member = member; + this.bodyType = bodyType; + this.content = content; + this.isPublic = isPublic; + this.likeCount = 0; + this.reportCount = 0; + this.images = new ArrayList<>(); + } + + public void decreaseLikeCount() { + this.likeCount--; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void increaseReportCount() { + this.reportCount++; + } + + public void updatePost(String content, boolean isPublic) { + this.content = content; + this.isPublic = isPublic; + } +} diff --git a/src/main/java/com/example/mody/domain/post/entity/PostImage.java b/src/main/java/com/example/mody/domain/post/entity/PostImage.java new file mode 100644 index 00000000..1b71ffaf --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/entity/PostImage.java @@ -0,0 +1,30 @@ +package com.example.mody.domain.post.entity; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_image") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class PostImage extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "post_image_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(nullable = false) + private String url; + + public PostImage(Post post, String s3Url){ + this.post = post; + this.url = s3Url; + } +} diff --git a/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java b/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java new file mode 100644 index 00000000..0595a9b0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java @@ -0,0 +1,55 @@ +package com.example.mody.domain.post.entity.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.global.common.base.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "member_post_like") +public class MemberPostLike extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_post_like_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + public static MemberPostLike createMemberPostLike(Member member, Post post) { + return MemberPostLike.builder() + .member(member) + .post(post) + .build(); + } + +} diff --git a/src/main/java/com/example/mody/domain/post/entity/mapping/PostReport.java b/src/main/java/com/example/mody/domain/post/entity/mapping/PostReport.java new file mode 100644 index 00000000..723ece61 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/entity/mapping/PostReport.java @@ -0,0 +1,36 @@ +package com.example.mody.domain.post.entity.mapping; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "post_report") +public class PostReport extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private Post post; + + public PostReport(Member member, Post post) { + this.member = member; + this.post = post; + } +} diff --git a/src/main/java/com/example/mody/domain/post/exception/annotation/ExistsPost.java b/src/main/java/com/example/mody/domain/post/exception/annotation/ExistsPost.java new file mode 100644 index 00000000..fe5a9bfe --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/exception/annotation/ExistsPost.java @@ -0,0 +1,25 @@ +package com.example.mody.domain.post.exception.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.mody.domain.post.exception.validator.ExistsPostValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ExistsPostValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistsPost { + + String message() default "게시글이 존재하지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/mody/domain/post/exception/validator/ExistsPostValidator.java b/src/main/java/com/example/mody/domain/post/exception/validator/ExistsPostValidator.java new file mode 100644 index 00000000..dc54dc1a --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/exception/validator/ExistsPostValidator.java @@ -0,0 +1,34 @@ +package com.example.mody.domain.post.exception.validator; + +import org.springframework.stereotype.Component; + +import com.example.mody.domain.post.exception.annotation.ExistsPost; +import com.example.mody.domain.post.repository.PostRepository; +import com.example.mody.global.common.exception.code.status.PostErrorStatus; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ExistsPostValidator implements ConstraintValidator { + + private final PostRepository postRepository; + + @Override + public boolean isValid(Long postId, ConstraintValidatorContext context) { + + if (postId == null || !postRepository.existsById(postId)) { + + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate( + PostErrorStatus.POST_NOT_FOUND.getMessage() + ).addConstraintViolation(); + + return false; + } else { + return true; + } + } +} diff --git a/src/main/java/com/example/mody/domain/post/repository/MemberPostLikeRepository.java b/src/main/java/com/example/mody/domain/post/repository/MemberPostLikeRepository.java new file mode 100644 index 00000000..2734165f --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/repository/MemberPostLikeRepository.java @@ -0,0 +1,15 @@ +package com.example.mody.domain.post.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.domain.post.entity.mapping.MemberPostLike; + +public interface MemberPostLikeRepository extends JpaRepository { + + Optional findByPostAndMember(Post post, Member member); + Optional findByMemberAndPostId(Member member, Long postId); +} diff --git a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java new file mode 100644 index 00000000..dafacb5a --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java @@ -0,0 +1,17 @@ +package com.example.mody.domain.post.repository; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse; +import com.example.mody.domain.post.entity.Post; +import jakarta.persistence.EntityManager; + +import java.util.Optional; + +public interface PostCustomRepository { + + public PostListResponse getPostList(Optional cursorPost, Integer size, Member member, Optional bodyType); + public LikedPostsResponse getLikedPosts(Long cursor, Integer size, Member member); + public PostListResponse getMyPosts(Long cursor, Integer size, Member member); +} diff --git a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java new file mode 100644 index 00000000..c3fbfeec --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java @@ -0,0 +1,287 @@ +package com.example.mody.domain.post.repository; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.entity.QMember; +import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponse; +import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.domain.post.entity.QPost; +import com.example.mody.domain.post.entity.QPostImage; +import com.example.mody.domain.post.entity.mapping.QMemberPostLike; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.ConstantImpl; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.*; +import com.querydsl.core.types.dsl.StringTemplate; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.function.BiFunction; + +@Repository +@RequiredArgsConstructor +@Slf4j +public class PostCustomRepositoryImpl implements PostCustomRepository{ + + private final JPAQueryFactory jpaQueryFactory; + + private final QPost qPost = QPost.post; + private final QMember qMember = QMember.member; + private final QMemberPostLike qMemberPostLike = QMemberPostLike.memberPostLike; + private final QPostImage qPostImage = QPostImage.postImage; + + + /** + * 게시글 전체 조회. 조회할 post 목록을 조회하는 쿼리, 해당 목록의 데이터들을 가져오는 쿼리, 각 게시글 별 좋아요 여부 확인 쿼리가 날라감. + * @param cursorPost + * @param size + * @param member + * @param bodyType + * @return + */ + @Override + public PostListResponse getPostList(Optional cursorPost, Integer size, Member member, Optional bodyType) { + BooleanBuilder predicate = new BooleanBuilder(); + + predicate.and(qPost.isPublic.eq(true)); // 공개여부 == true + + if(cursorPost.isPresent()){ + String customCursor = createCustomCursor(cursorPost.get(), bodyType); + log.info(customCursor); + predicate.and(applyCustomCursor(customCursor, bodyType)); + } + + BooleanExpression isLiked = Expressions.asBoolean(false); + if (member != null){ + isLiked = isLikedResult(member); + } + + //동적 정렬 + List> orderSpecifiers = new ArrayList<>(); + if (bodyType.isPresent()) { + orderSpecifiers.add(matchedBodyTypeAsInteger(bodyType).desc()); // bodyType이 존재할 때만 정렬 조건 추가 + } + orderSpecifiers.add(qPost.createdAt.desc()); // 항상 createdAt으로 정렬 + + List postIds = jpaQueryFactory + .select(qPost.id) + .from(qPost) + .where(predicate) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인 + .fetch(); + + Map postResponseMap = jpaQueryFactory + .from(qPost) + .leftJoin(qPost.member, qMember) + .leftJoin(qPost.images, qPostImage) + .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost)) + .where(qPost.id.in(postIds)) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .transform(GroupBy.groupBy(qPost.id).as( + Projections.constructor(PostResponse.class, + qPost.id, + qMember.id, + qMember.nickname, + qPost.content, + qPost.isPublic, + qPost.likeCount, + isLiked, + qPost.bodyType.name, + GroupBy.list(qPostImage) + ))); + + List postResponses = new ArrayList<>(postResponseMap.values()); + + return PostListResponse.of(hasNext.apply(postResponses, size), + postResponses.subList(0, Math.min(size, postResponses.size()))); + } + + /** + * 좋아요 누른 게시글 목록 조회 + * @param cursor 좋아요의 id + * @param size + * @param member + * @return + */ + public LikedPostsResponse getLikedPosts(Long cursor, Integer size, Member member) { + BooleanBuilder predicate = new BooleanBuilder(); + + if(cursor != null){ + predicate.and(qMemberPostLike.id.lt(cursor)); + } + + predicate.and(qMemberPostLike.member.eq(member)); + + List postIds = jpaQueryFactory + .select(qPost.id) + .from(qPost) + .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost).and(qMemberPostLike.member.eq(member))) + .where(predicate) + .orderBy(qMemberPostLike.createdAt.desc()) + .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인 + .fetch(); + + Map postResponseMap = jpaQueryFactory + .from(qPost) + .leftJoin(qPost.member, qMember) + .leftJoin(qPost.images, qPostImage) + .where(qPost.id.in(postIds)) + .transform(GroupBy.groupBy(qPost.id).as( + Projections.constructor(PostResponse.class, + qPost.id, + qMember.id, + qMember.nickname, + qPost.content, + qPost.isPublic, + qPost.likeCount, + Expressions.asBoolean(Expressions.TRUE), + qPost.bodyType.name, + GroupBy.list(qPostImage) + ))); + + List postResponses = postIds.stream() + .map(postResponseMap::get) + .filter(Objects::nonNull) + .toList(); + + return new LikedPostsResponse( + hasNext.apply(postResponses, size), + postResponses.subList(0, Math.min(size, postResponses.size()))); + } + + @Override + public PostListResponse getMyPosts(Long cursor, Integer size, Member member) { + BooleanBuilder predicate = new BooleanBuilder(); + + predicate.and(qPost.member.eq(member)); + + if(cursor != null){ + predicate.and(qPost.id.lt(cursor)); + } + + log.info(predicate.toString()); + + List postIds = jpaQueryFactory + .select(qPost.id) + .from(qPost) + .where(predicate) + .orderBy(qPost.createdAt.desc()) + .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인 + .fetch(); + + Map postResponseMap = jpaQueryFactory + .from(qPost) + .leftJoin(qPost.member, qMember) + .leftJoin(qPost.images, qPostImage) + .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost)) + .where(qPost.id.in(postIds)) + .orderBy( qPost.createdAt.desc()) + .transform(GroupBy.groupBy(qPost.id).as( + Projections.constructor(PostResponse.class, + qPost.id, + qMember.id, + qMember.nickname, + qPost.content, + qPost.isPublic, + qPost.likeCount, + isLikedResult(member), + qPost.bodyType.name, + GroupBy.list(qPostImage) + ))); + + List postResponses = new ArrayList<>(postResponseMap.values()); + + return PostListResponse.of(hasNext.apply(postResponses, size), + postResponses.subList(0, Math.min(size, postResponses.size()))); + } + + private BiFunction , Integer, Boolean> hasNext = (list, size) -> list.size() > size; + + /** + * // 정렬 기준. 특정 바디 타입이 요구되지 않으면 전부 1 + * @param bodyType + * @return + */ + private NumberExpression matchedBodyTypeAsInteger(Optional bodyType){ + if(bodyType.isPresent()){ + return new CaseBuilder() + .when(qPost.bodyType.eq(bodyType.get())).then(1) + .otherwise(0); + } + return Expressions.asNumber(0); + } + + private StringExpression matchedBodyTypeAsString(Optional bodyType){ + if(bodyType.isPresent()){ + return new CaseBuilder() + .when(qPost.bodyType.eq(bodyType.get())).then("1") + .otherwise("0"); + } + return Expressions.asString("0"); + } + + private BooleanExpression isLikedResult(Member member){ + return JPAExpressions + .selectFrom(qMemberPostLike) + .where(qMemberPostLike.member.eq(member).and(qMemberPostLike.post.eq(qPost))) + .exists(); + } + + private String createCustomCursor(Post cursor, Optional bodyType){ + if (cursor == null || bodyType == null) { + return null; + } + + String isMatchedBodyType = "0"; + if(bodyType.isPresent()){ + if(cursor.getBodyType().getId().equals(bodyType.get().getId())){ + isMatchedBodyType = "1"; + } + } + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + return isMatchedBodyType + String.format("%19s", formatter.format(cursor.getCreatedAt())).replaceAll(" ","0"); + } + + /** + * createdAt을 'YYYYMMDDHHmmss' 로 변환 + * @return + */ + private StringTemplate mySqlDateFormat(){ + return Expressions.stringTemplate( + "CAST(DATE_FORMAT({0}, {1}) AS STRING)", + qPost.createdAt, + ConstantImpl.create("%Y%m%d%H%i%s") + ); + } + + private StringExpression formatCreatedAt(StringTemplate stringTemplate){ + return StringExpressions.lpad(stringTemplate, 19, '0'); + } + + private BooleanExpression applyCustomCursor(String customCursor, Optional bodyType) { + if (customCursor == null) { // 커서가 없으면 조건 없음 + return null; + } + + // bodyType 순서 계산 + StringExpression isMatchedBodyType = matchedBodyTypeAsString(bodyType); // bodyType 일치 여부 + StringTemplate postCreatedAtTemplate = mySqlDateFormat(); //DATE_FORMAT으로 날짜 형태 변경 + StringExpression formattedCreatedAt = formatCreatedAt(postCreatedAtTemplate); // 날짜 형태 변경을 포맷 + + return isMatchedBodyType.concat(formattedCreatedAt) + .lt(customCursor); + } + +} diff --git a/src/main/java/com/example/mody/domain/post/repository/PostImageRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostImageRepository.java new file mode 100644 index 00000000..54694c5c --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/repository/PostImageRepository.java @@ -0,0 +1,16 @@ +package com.example.mody.domain.post.repository; + +import com.example.mody.domain.post.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PostImageRepository extends JpaRepository { + @Query("SELECT pi.id FROM PostImage pi WHERE pi.post.id = :postId") + List findPostImageIdByPostId(@Param("postId") Long postId); + + @Query("SELECT pi.url FROM PostImage pi WHERE pi.post.id = :postId") + List findPostImageUrlByPostId(@Param("postId") Long postId); +} diff --git a/src/main/java/com/example/mody/domain/post/repository/PostReportRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostReportRepository.java new file mode 100644 index 00000000..a7d89bef --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/repository/PostReportRepository.java @@ -0,0 +1,11 @@ +package com.example.mody.domain.post.repository; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.domain.post.entity.mapping.PostReport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostReportRepository extends JpaRepository { + boolean existsByMemberAndPost(Member member, Post post); + void deleteAllByPost(Post post); +} diff --git a/src/main/java/com/example/mody/domain/post/repository/PostRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostRepository.java new file mode 100644 index 00000000..0942bcfd --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/repository/PostRepository.java @@ -0,0 +1,12 @@ +package com.example.mody.domain.post.repository; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PostRepository extends JpaRepository, PostCustomRepository { +} diff --git a/src/main/java/com/example/mody/domain/post/service/PostCommandService.java b/src/main/java/com/example/mody/domain/post/service/PostCommandService.java new file mode 100644 index 00000000..c5cfb86e --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/service/PostCommandService.java @@ -0,0 +1,18 @@ +package com.example.mody.domain.post.service; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.dto.request.PostCreateRequest; +import com.example.mody.domain.post.dto.request.PostUpdateRequest; +import com.example.mody.domain.post.dto.response.PostResponse; +import com.example.mody.domain.post.entity.Post; + +public interface PostCommandService { + public void createPost(PostCreateRequest postCreateRequest, Member member); + + public void deletePost(Long postId, Member member); + + void togglePostLike(Long myId, Long postId); + + public void reportPost(Member member, Long postId); + public void updatePost(PostUpdateRequest request, Long postId, Member member); +} diff --git a/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java b/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java new file mode 100644 index 00000000..286105dc --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java @@ -0,0 +1,192 @@ +package com.example.mody.domain.post.service; + +import static com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus.*; +import static com.example.mody.global.common.exception.code.status.PostErrorStatus.*; + +import java.util.List; +import java.util.Optional; + + +import com.example.mody.domain.file.repository.BackupFileRepository; +import com.example.mody.domain.file.service.FileService; +import com.example.mody.domain.image.service.S3Service; +import com.example.mody.domain.post.dto.request.PostUpdateRequest; +import com.example.mody.domain.post.entity.mapping.PostReport; +import com.example.mody.domain.post.repository.PostReportRepository; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.S3ErrorStatus; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.service.BodyTypeService; +import com.example.mody.domain.exception.BodyTypeException; +import com.example.mody.domain.exception.MemberException; +import com.example.mody.domain.exception.PostException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.domain.post.dto.request.PostCreateRequest; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.domain.post.entity.PostImage; +import com.example.mody.domain.post.entity.mapping.MemberPostLike; +import com.example.mody.domain.post.repository.MemberPostLikeRepository; +import com.example.mody.domain.post.repository.PostImageRepository; +import com.example.mody.domain.post.repository.PostRepository; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; +import com.example.mody.global.common.exception.code.status.PostErrorStatus; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@Service +@Transactional +@RequiredArgsConstructor +public class PostCommandServiceImpl implements PostCommandService { + private final PostRepository postRepository; + private final MemberRepository memberRepository; + private final MemberPostLikeRepository postLikeRepository; + private final PostImageRepository postImageRepository; + private final BackupFileRepository backupFileRepository; + private final PostReportRepository postReportRepository; + private final BodyTypeService bodyTypeService; + private final FileService fileService; + private final RestTemplate restTemplate; + private final S3Service s3Service; + + /** + * 게시글 작성 비즈니스 로직. BodyType은 요청 유저의 가장 마지막 BodyType을 적용함. 유저의 BodyType이 존재하지 않을 경우 예외 발생. + * @param postCreateRequest + * @param member + */ + @Override + public void createPost(PostCreateRequest postCreateRequest, Member member) { + Optional optionalBodyType = bodyTypeService.findLastBodyType(member); + + BodyType bodyType = optionalBodyType.orElseThrow(() -> new BodyTypeException(MEMBER_BODY_TYPE_NOT_FOUND)); + + Post post = new Post(member, + bodyType, + postCreateRequest.getContent(), + postCreateRequest.getIsPublic()); + + postCreateRequest.getS3Urls().forEach(s3Url -> { + validateS3Url(s3Url); // 유효한 S3 url인지 검증 + PostImage postImage = new PostImage(post, s3Url); + post.getImages().add(postImage); + }); + + postRepository.save(post); + } + + private void validateS3Url(String s3Url) { + try { + // S3 url에 GET 요청을 보내서 유효한지 확인 + restTemplate.exchange(s3Url, HttpMethod.GET, null, Void.class); + } catch (HttpClientErrorException e) { + throw new RestApiException(S3ErrorStatus.OBJECT_NOT_FOUND); + } + } + + @Override + @Transactional + public void deletePost(Long postId, Member member) { + // 게시글 조회 및 존재 여부 확인 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostException(POST_NOT_FOUND)); + + checkAuthorization(member, post); + delete(post); + } + + @Override + public void togglePostLike(Long myId, Long postId) { + + // 게시글과 사용자 존재 여부는 @ExistsPost와 @ExistsMember 어노테이션으로 이미 검증되었습니다. + // 하지만 실제 엔티티가 필요하므로 조회합니다. + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostException(PostErrorStatus.POST_NOT_FOUND)); + + Member member = memberRepository.findById(myId) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + // 이미 좋아요를 눌렀는지 확인합니다. + Optional existingLike = postLikeRepository.findByPostAndMember(post, member); + + if (existingLike.isPresent()) { + // 이미 좋아요가 있다면 취소(삭제)합니다. + postLikeRepository.delete(existingLike.get()); + // 게시글의 좋아요 수를 감소시킵니다. + post.decreaseLikeCount(); + post.getMember().decreaseLikeCount(); + } else { + // 좋아요가 없다면 새로 생성합니다. + MemberPostLike postLike = MemberPostLike.createMemberPostLike(member, post); + postLikeRepository.save(postLike); + // 게시글의 좋아요 수를 증가시킵니다. + post.increaseLikeCount(); + post.getMember().increaseLikeCount(); + } + + // 게시글 엔티티는 @Transactional에 의해 자동으로 저장됩니다. + } + + @Override + public void reportPost(Member member, Long postId) { + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostException(POST_NOT_FOUND)); + + // 이미 신고를 했는지 확인 + if (postReportRepository.existsByMemberAndPost(member, post)) { + throw new PostException(POST_ALREADY_REPORT); + } + + // 신고 기록 저장 + PostReport postReport = new PostReport(member, post); + postReportRepository.save(postReport); + + // 신고 횟수 증가 + post.getMember().increaseReportCount(); + post.increaseReportCount(); + + // 신고 횟수가 10회 이상이면 게시글 삭제 + if (post.getReportCount() >= 10) { + postReportRepository.deleteAllByPost(post); + delete(post); + } + } + + @Override + public void updatePost(PostUpdateRequest request, Long postId, Member member){ + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostException(POST_NOT_FOUND)); + checkAuthorization(member, post); + post.updatePost(request.getContent(), request.getIsPublic()); + } + + @Transactional + protected void delete(Post post) { + List postImageUrls = postImageRepository.findPostImageUrlByPostId(post.getId()); + + // S3 파일 삭제 + s3Service.deleteFile(postImageUrls); + + // DB에서 Post 관련 데이터 삭제 + fileService.deleteByS3Urls(postImageUrls); + decreaseLikeCount(post); + postRepository.deleteById(post.getId()); // (cascade = CascadeType.ALL, orphanRemoval = true) -> postId에 해당하는 PostImage 전부 삭제됨 + } + + private void decreaseLikeCount(Post post){ + Integer postLikeCount = post.getLikeCount(); + post.getMember().decreaseLikeCount(postLikeCount); + } + + private void checkAuthorization(Member member, Post post){ + if(! member.equals(post.getMember())){ + throw new PostException(POST_FORBIDDEN); + } + } +} diff --git a/src/main/java/com/example/mody/domain/post/service/PostQueryService.java b/src/main/java/com/example/mody/domain/post/service/PostQueryService.java new file mode 100644 index 00000000..430729be --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/service/PostQueryService.java @@ -0,0 +1,12 @@ +package com.example.mody.domain.post.service; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponse; + +public interface PostQueryService { + public PostListResponse getPosts(Member member, Integer size, Long cursor); + public PostListResponse getLikedPosts(Member member, Integer size, Long cursor); + public PostListResponse getMyPosts(Member member, Integer size, Long cursor); + public PostResponse getPost(Member member, Long postId); +} diff --git a/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java b/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java new file mode 100644 index 00000000..ecc2e9dc --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java @@ -0,0 +1,105 @@ +package com.example.mody.domain.post.service; + +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.service.BodyTypeService; +import com.example.mody.domain.exception.PostException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponse; +import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse; +import com.example.mody.domain.post.entity.Post; +import com.example.mody.domain.post.entity.mapping.MemberPostLike; +import com.example.mody.domain.post.repository.MemberPostLikeRepository; +import com.example.mody.domain.post.repository.PostRepository; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.GlobalErrorStatus; +import com.example.mody.global.common.exception.code.status.MemberPostLikeErrorStatus; +import com.example.mody.global.common.exception.code.status.PostErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static com.example.mody.global.common.exception.code.status.PostErrorStatus.POST_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class PostQueryServiceImpl implements PostQueryService { + private final BodyTypeService bodyTypeService; + private final PostRepository postRepository; + private final MemberPostLikeRepository memberPostLikeRepository; + private final MemberPostLikeRepository postLikeRepository; + + @Override + @Transactional(readOnly = true) + public PostListResponse getPosts(Member member, Integer size, Long cursor) { + + if(size<=0){ + throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST); + } + + Optional cursorPost = getCursorPost(cursor); + + if(member != null){ + Optional bodyTypeOptional = bodyTypeService.findLastBodyType(member); + return postRepository.getPostList(cursorPost, size, member, bodyTypeOptional); + } + + return postRepository.getPostList(cursorPost, size, member, Optional.empty()); + } + + @Override + @Transactional(readOnly = true) + public PostListResponse getLikedPosts(Member member, Integer size, Long cursor) { + if(size<=0){ + throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST); + } + LikedPostsResponse likedPostsResponse = postRepository.getLikedPosts(cursor, size, member); + + Optional nextLike = Optional.empty(); + if(likedPostsResponse.hasNext()){ + PostResponse postResponse = likedPostsResponse.postResponses().getLast(); + nextLike = Optional.ofNullable(findByMemberAndPostId(member, postResponse.getPostId()).getId()); + } + + return PostListResponse.of(likedPostsResponse.hasNext(), likedPostsResponse.postResponses(), nextLike.orElse(null)); + } + + @Override + @Transactional(readOnly = true) + public PostListResponse getMyPosts(Member member, Integer size, Long cursor) { + if(size<=0){ + throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST); + } + return postRepository.getMyPosts(cursor, size, member); + } + + public Optional getCursorPost(Long cursor){ + return cursor != null ? Optional.ofNullable(findById(cursor)) : Optional.empty(); + } + + public Post findById(Long id){ + return postRepository.findById(id).orElseThrow(()-> + new PostException(PostErrorStatus.POST_NOT_FOUND)); + } + + public MemberPostLike findByMemberAndPostId(Member member, Long postId){ + return memberPostLikeRepository.findByMemberAndPostId(member, postId).orElseThrow(() + -> new PostException(MemberPostLikeErrorStatus.LIKE_NOT_FOUND)); + } + + @Override + public PostResponse getPost(Member member, Long postId){ + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostException(POST_NOT_FOUND)); + + Optional existingLike = postLikeRepository.findByPostAndMember(post, member); + + PostResponse postResponse = new PostResponse(post.getId(), post.getMember().getId(),post.getMember().getNickname(), post.getContent(), post.getIsPublic(), post.getLikeCount(), existingLike.isPresent() ,post.getBodyType().getName(), post.getImages()); + + return postResponse; + } + + +} diff --git a/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationController.java new file mode 100644 index 00000000..0da5e2a8 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationController.java @@ -0,0 +1,84 @@ +package com.example.mody.domain.recommendation.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.recommendation.dto.request.RecommendRequest; +import com.example.mody.domain.recommendation.dto.response.CategoryResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendLikeResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendResponses; +import com.example.mody.domain.recommendation.service.RecommendationCommendService; +import com.example.mody.domain.recommendation.service.RecommendationQueryService; +import com.example.mody.global.common.base.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "추천", description = "추천 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/recommendations") +public class RecommendationController implements RecommendationControllerInterface { + + private final RecommendationQueryService recommendationQueryService; + private final RecommendationCommendService recommendationCommendService; + + + // todo : 피그마에 있는 카테고리 순서대로 주기 + // 추천 카테고리 조회 + @GetMapping("/categories") + public BaseResponse getStyleCategories() { + //서비스에서 데이터를 조회하여 반환 + CategoryResponse categoryResponse = recommendationQueryService.getCategories(); + return BaseResponse.onSuccess(categoryResponse); + } + + // 스타일 추천 좋아요 + @PostMapping("/{recommendationId}/like") + public BaseResponse toggleStyleLike( + @PathVariable(name = "recommendationId") Long recommendationId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + RecommendLikeResponse response = recommendationCommendService.toggleLike(recommendationId, customUserDetails.getMember()); + return BaseResponse.onSuccess(response); + } + + // 추천 결과 조회 + @GetMapping + public BaseResponse getRecommendations( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "cursor", required = false) Long cursor, + @RequestParam(name = "size", defaultValue = "15") Integer size + ) { + RecommendResponses responses = recommendationQueryService.getRecommendations(customUserDetails.getMember(), size, cursor); + return BaseResponse.onSuccess(responses); + } + + @PostMapping("/style-analysis") + // 스타일 추천 API + public BaseResponse recommendStyle( + @Valid @RequestBody RecommendRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + RecommendResponse response = recommendationCommendService.recommendStyle( + customUserDetails.getMember(), request); + return BaseResponse.onSuccess(response); + } + + // 스타일 아이템 추천 API + @PostMapping("/fashion-item-analysis") + public BaseResponse recommendFashionItem( + @Valid @RequestBody RecommendRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + RecommendResponse response = recommendationCommendService.recommendFashionItem( + customUserDetails.getMember(), request); + return BaseResponse.onSuccess(response); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationControllerInterface.java b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationControllerInterface.java new file mode 100644 index 00000000..8a68a82b --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationControllerInterface.java @@ -0,0 +1,200 @@ +package com.example.mody.domain.recommendation.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.recommendation.dto.request.RecommendRequest; +import com.example.mody.domain.recommendation.dto.response.CategoryResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendLikeResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendResponses; +import com.example.mody.global.common.base.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +public interface RecommendationControllerInterface { + + @Operation(summary = "추천 카테고리 조회 API", description = "추천을 위한 스타일, 어필 카테고리를 반환하는 API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "스타일 카테고리 조회 성공", + content = @Content(schema = @Schema(implementation = CategoryResponse.class)) + ) + }) + BaseResponse getStyleCategories(); + + + @Operation(summary = "스타일 추천 좋아요 API", description = "스타일 추천에 대한 좋아요 기능") + @PostMapping("/{recommendationId}/like") + @ApiResponses({ + @ApiResponse(responseCode = "COMMON200", description = "스타일 추천에 좋아요 성공"), + @ApiResponse(responseCode = "RECOMMENDATION404", description = "요청한 스타일 추천 결과물이 존재하지 않는 경우") + }) + BaseResponse toggleStyleLike(Long recommendationId,CustomUserDetails customUserDetails); + + + @Operation(summary = "추천 결과 조회 API", description = "사용자가 추천받은 결과를 마이페이지에서 조회하는 api입니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "스타일 추천 성공", + content = @Content(schema = @Schema(implementation = RecommendResponses.class)) + ), + @ApiResponse( + responseCode = "RECOMMENDATION404", + description = "사용자가 추천받은 결과가 없을 때 발생합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-17T00:48:53.9237864", + "code": "RECOMMENDATION404", + "message": "해당 추천 결과를 찾을 수 없습니다." + } + """ + ) + ) + ) + }) + BaseResponse getRecommendations( + CustomUserDetails customUserDetails, + Long cursor, + Integer size + ); + + @Operation(summary = "스타일 추천 API", description = "스타일 분석 후 그 결과를 반환합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "스타일 추천 성공", + content = @Content(schema = @Schema(implementation = RecommendResponse.class)) + ), + @ApiResponse( + responseCode = "MEMBER_BODY_TYPE404", + description = "사용자의 체형 정보를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-17T00:48:53.9237864", + "code": "MEMBER_BODY_TYPE404", + "message": "체형 분석 결과를 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "COMMON402", + description = "카테고리 리스트가 비어있을 때 발생합니다. " + + "선호하는 스타일/ 선호하지 않는 스타일/ 보여주고 싶은 이미지 목록으로 표시됩니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-25T15:57:08.7901651", + "code": "COMMON402", + "message": "Validation Error입니다.", + "result": { + "preferredStyles": "선호하는 스타일 목록은 비어 있을 수 없습니다." + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "ANALYSIS108", + description = "GPT 응답 형식이 적절하지 않을 때 발생합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-25T16:02:42.4014717", + "code": "ANALYSIS108", + "message": "GPT가 올바르지 않은 답변을 했습니다. 관리자에게 문의하세요." + } + """ + ) + ) + ) + }) + BaseResponse recommendStyle( + RecommendRequest request, + CustomUserDetails customUserDetails); + + @Operation(summary = "패션 아이템 추천 API", description = "패션아이템 분석 후 그 결과를 반환합니다.") + @PostMapping("/fashion-item-analysis") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "패션 아이템 추천 성공", + content = @Content(schema = @Schema(implementation = RecommendResponse.class)) + ), + @ApiResponse( + responseCode = "MEMBER_BODY_TYPE404", + description = "사용자의 체형 정보를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-17T00:48:53.9237864", + "code": "MEMBER_BODY_TYPE404", + "message": "체형 분석 결과를 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "COMMON402", + description = "카테고리 리스트가 비어있을 때 발생합니다. " + + "선호하는 스타일/ 선호하지 않는 스타일/ 보여주고 싶은 이미지 목록으로 표시됩니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-25T15:57:08.7901651", + "code": "COMMON402", + "message": "Validation Error입니다.", + "result": { + "preferredStyles": "선호하는 스타일 목록은 비어 있을 수 없습니다." + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "ANALYSIS108", + description = "GPT 응답 형식이 적절하지 않을 때 발생합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-25T16:02:42.4014717", + "code": "ANALYSIS108", + "message": "GPT가 올바르지 않은 답변을 했습니다. 관리자에게 문의하세요." + } + """ + ) + ) + ) + }) + BaseResponse recommendFashionItem( + RecommendRequest request, + CustomUserDetails customUserDetails); +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/request/MemberInfoRequest.java b/src/main/java/com/example/mody/domain/recommendation/dto/request/MemberInfoRequest.java new file mode 100644 index 00000000..e39fd5a8 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/request/MemberInfoRequest.java @@ -0,0 +1,33 @@ +package com.example.mody.domain.recommendation.dto.request; + +import com.example.mody.domain.member.enums.Gender; +import lombok.*; + +/** + * 추천에 필요한 사용자 정보를 담을 DTO + * - 추후 변경 예정 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberInfoRequest { + private String nickName; + + private Gender gender; + + //gpt를 통한 체형 분석 내용 + private String body; + + //체형 타입 이름 + private String bodyTypeName; + + public static MemberInfoRequest of(String nickName, Gender gender,String body, String bodyTypeName) { + return MemberInfoRequest.builder() + .nickName(nickName) + .gender(gender) + .body(body) + .bodyTypeName(bodyTypeName) + .build(); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/request/RecommendRequest.java b/src/main/java/com/example/mody/domain/recommendation/dto/request/RecommendRequest.java new file mode 100644 index 00000000..0d16a052 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/request/RecommendRequest.java @@ -0,0 +1,39 @@ +package com.example.mody.domain.recommendation.dto.request; + +import com.example.mody.global.common.exception.annotation.IsEmptyList; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +@Schema(description = "스타일 추천 요청 DTO") +@Data +@Getter +public class RecommendRequest { + + @Schema( + description = "선호하는 스타일", + example = "[\"힙/스트릿\",\"빈티지\"]" + ) + @NotNull(message = "선호하는 스타일은 필수 항목입니다.") + @IsEmptyList(message = "선호하는 스타일 목록은 비어 있을 수 없습니다.") + private List preferredStyles; + + @Schema( + description = "사용자가 선호하지 않는 스타일 목록 (예: 포멀 등)", + example = "[\"페미닌\", \"러블리\"]" + ) + @NotNull(message = "선호하지 않는 스타일은 필수 항목입니다.") + @IsEmptyList(message = "선호하지 않는 스타일 목록은 비어 있을 수 없습니다.") + private List dislikedStyles; + + @Schema( + description = "사용자가 보여주고 싶은 이미지 설명 (예: 세련되고 자유로운 이미지)", + example = "[\"섹시한\", \"시크한\"]" + ) + @NotNull(message = "보여주고 싶은 이미지는 필수 항목입니다.") + @IsEmptyList(message = "보여주고 싶은 이미지 목록은 비어 있을 수 없습니다.") + private List appealedImage; +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/response/CategoryResponse.java b/src/main/java/com/example/mody/domain/recommendation/dto/response/CategoryResponse.java new file mode 100644 index 00000000..2b707d0f --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/response/CategoryResponse.java @@ -0,0 +1,37 @@ +package com.example.mody.domain.recommendation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +@Schema(description = "스타일 카테고리 조회 DTO") +@Getter +@Data +@AllArgsConstructor +public class CategoryResponse { + + @Schema( + description = "스타일 카테고리", + example = "[\n" + + " \"캐주얼\",\n" + + " \"컴퍼스룩\",\n" + + " \"스트릿\",\n" + + " \"컴퍼스룩\",\n" + + " \"스트릿\"]," + ) + private List styleCategories; + + @Schema( + description = "보여지고 싶은 이미지 카테고리", + example = "[\n" + + " \"지적인\",\n" + + " \"섹시한\",\n" + + " \"귀여운\",\n" + + " \"지적인\",\n" + + " ]" + ) + private List appealCategories; +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendLikeResponse.java b/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendLikeResponse.java new file mode 100644 index 00000000..372e2ed7 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendLikeResponse.java @@ -0,0 +1,34 @@ +package com.example.mody.domain.recommendation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(description = "추천 좋아요 응답") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RecommendLikeResponse { + + @Schema( + description = "멤버 아이디", + example = "1" + ) + private Long memberId; + + @Schema( + description = "추천 아이디", + example = "1" + ) + private Long recommendId; + + @Schema( + description = "좋아요 여부", + example = "true" + ) + private boolean isLiked; + + public void setLiked(boolean liked) { + isLiked = liked; + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendResponse.java b/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendResponse.java new file mode 100644 index 00000000..3590085c --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendResponse.java @@ -0,0 +1,64 @@ +package com.example.mody.domain.recommendation.dto.response; + +import com.example.mody.domain.recommendation.enums.RecommendType; +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(description = "추천 응답") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecommendResponse { + + @Schema( + description = "회원 아이디", + example = "1" + ) + private Long memberId; + + @Schema( + description = "회원 닉네임", + example = "영희" + ) + private String nickname; + + @Schema(description = "추천 아이디") + private Long recommendationId; + + @Schema(description = "추천 타입") + private RecommendType recommendType; + + @Schema(description = "좋아요 눌렀는지 여부") + private boolean isLiked = false; + + private String title; + + private String content; + + private String imageUrl; + + public static RecommendResponse of(Long memberId, String nickname, Long recommendationId, RecommendType recommendType, String title, String content, String imageUrl) { + return RecommendResponse.builder() + .memberId(memberId) + .nickname(nickname) + .recommendationId(recommendationId) + .recommendType(recommendType) + .title(title) + .content(content) + .imageUrl(imageUrl) + .build(); + } + + @QueryProjection + public RecommendResponse(Long memberId, String nickname, Long recommendationId, RecommendType recommendType, boolean isLiked, String title, String content, String imageUrl) { + this.memberId = memberId; + this.nickname = nickname; + this.recommendationId = recommendationId; + this.recommendType = recommendType; + this.isLiked = isLiked; + this.title = title; + this.content = content; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendResponses.java b/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendResponses.java new file mode 100644 index 00000000..51baab11 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/response/RecommendResponses.java @@ -0,0 +1,25 @@ +package com.example.mody.domain.recommendation.dto.response; + +import com.example.mody.global.dto.response.CursorPagination; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.util.List; + +@Schema(description = "추천 결과 응답") +@Getter +@Builder +@AllArgsConstructor +public class RecommendResponses { + + @Schema(description = "추천 스타일 리스트") + private final List recommendResponseList; + + @Schema(description = "커서 기반 페이지네이션") + private final CursorPagination cursorPagination; + + public static RecommendResponses of(Boolean hasNext, List recommendResponses){ + Long cursor = hasNext ? recommendResponses.getLast().getRecommendationId() : null; + return new RecommendResponses(recommendResponses, new CursorPagination(hasNext, cursor)); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/response/analysis/ItemAnalysisResponse.java b/src/main/java/com/example/mody/domain/recommendation/dto/response/analysis/ItemAnalysisResponse.java new file mode 100644 index 00000000..92b06307 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/response/analysis/ItemAnalysisResponse.java @@ -0,0 +1,38 @@ +package com.example.mody.domain.recommendation.dto.response.analysis; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(description = "패션 아이템 추천 Gpt 응답") +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ItemAnalysisResponse { + + private String nickName; + + @Schema( + description = "패션 아이템 이름", + example = "레더 자켓" + ) + private String item; + + @Schema( + description = "설명", + example = "네추럴 체형의 강인한 이미지를 살리면서도 힙/스트릿 및 빈티지 스타일과 잘 어우러지는 레더 자켓은 심세원님의 골격과 체형에 적합합니다. 어깨 라인을 강조하고, 시크하면서도 섹시한 이미지를 표현하는 데 효과적입니다. 슬림핏 혹은 크롭 스타일의 레더 자켓은 긴 다리와 상체 비율을 살려주는 아이템입니다." + ) + private String description; + + @Schema( + description = "이미지 url", + example = "https://i.pinimg.com/236x/32/87/f8/3287f86756200b3c8d9d28181aaddeae.jpg" + ) + private String imageUrl; + + public ItemAnalysisResponse from(String imageUrl) { + return this.toBuilder() + .imageUrl(imageUrl) + .build(); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/dto/response/analysis/StyleAnalysisResponse.java b/src/main/java/com/example/mody/domain/recommendation/dto/response/analysis/StyleAnalysisResponse.java new file mode 100644 index 00000000..f0257c6c --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/dto/response/analysis/StyleAnalysisResponse.java @@ -0,0 +1,50 @@ +package com.example.mody.domain.recommendation.dto.response.analysis; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(description = "스타일 추천 정보") +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class StyleAnalysisResponse { + + private String nickName; + + @Schema( + description = "추천하는 스타일", + example = "네추럴 스트릿 빈티지" + ) + public String recommendedStyle; + + @Schema( + description = "스타일 추천 배경", + example = "영희님의 체형은 네추럴 타입으로, 강인하고 조화로운 느낌을 주기 때문에... " + ) + private String introduction; + + @Schema( + description = "스타일 조언", + example = "쇄골과 어깨 라인을 드러내는 브이넥 탑이나 오프숄더 블라우스와 함께..." + ) + private String styleDirection; + + @Schema( + description = "실질적인 스타일 팁", + example = "벨트로 허리 라인을 강조하고, 볼륨감 있는 아우터를 활용하여..." + ) + private String practicalStylingTips; + + @Schema( + description = "이미지 url", + example = "https://i.pinimg.com/236x/ad/c4/4f/adc44f56293a18fdb15f8b5fa5067dab.jpg" + ) + private String imageUrl; + + public StyleAnalysisResponse from(String imageUrl) { + return this.toBuilder() + .imageUrl(imageUrl) + .build(); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/entity/Recommendation.java b/src/main/java/com/example/mody/domain/recommendation/entity/Recommendation.java new file mode 100644 index 00000000..40a53ca6 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/entity/Recommendation.java @@ -0,0 +1,75 @@ +package com.example.mody.domain.recommendation.entity; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike; +import com.example.mody.domain.recommendation.enums.RecommendType; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "recommendation") +public class Recommendation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recommendation_id") + private Long id; + + @Enumerated(EnumType.STRING) + private RecommendType recommendType; + + + private String title; + + @Lob + @Column(columnDefinition = "TEXT") + private String content; + + @Column(length = 300, nullable = false) + private String imageUrl; + + @Builder.Default + @Column(nullable = false, columnDefinition = "INTEGER DEFAULT 0") + private Integer likeCount = 0; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @OneToMany(mappedBy = "recommendation", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List RecommendLikes = new ArrayList<>(); + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + this.likeCount--; + } + + public static Recommendation of(RecommendType recommendType, String title, String content, String imageUrl, Member member) { + + return Recommendation.builder() + .recommendType(recommendType) + .title(title) + .content(content) + .imageUrl(imageUrl) + .member(member) + .build(); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/entity/category/AppealCategory.java b/src/main/java/com/example/mody/domain/recommendation/entity/category/AppealCategory.java new file mode 100644 index 00000000..f75dc967 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/entity/category/AppealCategory.java @@ -0,0 +1,18 @@ +package com.example.mody.domain.recommendation.entity.category; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class AppealCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "appeal_category_id") + private Long id; + + @Column(length = 50, nullable = false) + private String name; +} diff --git a/src/main/java/com/example/mody/domain/recommendation/entity/category/StyleCategory.java b/src/main/java/com/example/mody/domain/recommendation/entity/category/StyleCategory.java new file mode 100644 index 00000000..911818e3 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/entity/category/StyleCategory.java @@ -0,0 +1,18 @@ +package com.example.mody.domain.recommendation.entity.category; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class StyleCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "style_category_id") + private Long id; + + @Column(length = 50, nullable = false) + private String name; +} diff --git a/src/main/java/com/example/mody/domain/recommendation/entity/mapping/MemberRecommendationLike.java b/src/main/java/com/example/mody/domain/recommendation/entity/mapping/MemberRecommendationLike.java new file mode 100644 index 00000000..f10a7c3d --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/entity/mapping/MemberRecommendationLike.java @@ -0,0 +1,32 @@ +package com.example.mody.domain.recommendation.entity.mapping; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.entity.Recommendation; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member_recommendation_like") +public class MemberRecommendationLike extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_recommendation_like_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recommendation_id") + private Recommendation recommendation; + + public MemberRecommendationLike(Member member, Recommendation recommendation){ + this.member = member; + this.recommendation = recommendation; + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/enums/RecommendType.java b/src/main/java/com/example/mody/domain/recommendation/enums/RecommendType.java new file mode 100644 index 00000000..b09f0a2d --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/enums/RecommendType.java @@ -0,0 +1,5 @@ +package com.example.mody.domain.recommendation.enums; + +public enum RecommendType { + STYLE, FASHION_ITEM +} diff --git a/src/main/java/com/example/mody/domain/recommendation/exception/RecommendationException.java b/src/main/java/com/example/mody/domain/recommendation/exception/RecommendationException.java new file mode 100644 index 00000000..7ef590c8 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/exception/RecommendationException.java @@ -0,0 +1,11 @@ +package com.example.mody.domain.recommendation.exception; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +public class RecommendationException extends RestApiException { + + public RecommendationException(BaseCodeInterface errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/repository/MemberRecommendationLikeRepository.java b/src/main/java/com/example/mody/domain/recommendation/repository/MemberRecommendationLikeRepository.java new file mode 100644 index 00000000..39636029 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/repository/MemberRecommendationLikeRepository.java @@ -0,0 +1,14 @@ +package com.example.mody.domain.recommendation.repository; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.entity.Recommendation; +import com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRecommendationLikeRepository extends JpaRepository { + Optional findTopByMemberAndRecommendation(Member member, Recommendation recommendation); +} diff --git a/src/main/java/com/example/mody/domain/recommendation/repository/RecommendCustomRepository.java b/src/main/java/com/example/mody/domain/recommendation/repository/RecommendCustomRepository.java new file mode 100644 index 00000000..a2d0be6d --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/repository/RecommendCustomRepository.java @@ -0,0 +1,9 @@ +package com.example.mody.domain.recommendation.repository; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.dto.response.RecommendResponses; + +public interface RecommendCustomRepository { + + RecommendResponses findMyRecommendations(Member member, Integer size, Long Cursor); +} diff --git a/src/main/java/com/example/mody/domain/recommendation/repository/RecommendCustomRepositoryImpl.java b/src/main/java/com/example/mody/domain/recommendation/repository/RecommendCustomRepositoryImpl.java new file mode 100644 index 00000000..9b78abf1 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/repository/RecommendCustomRepositoryImpl.java @@ -0,0 +1,63 @@ +package com.example.mody.domain.recommendation.repository; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.entity.QMember; +import com.example.mody.domain.recommendation.dto.response.QRecommendResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendResponses; +import com.example.mody.domain.recommendation.dto.response.analysis.StyleAnalysisResponse; +import com.example.mody.domain.recommendation.entity.QRecommendation; +import com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.function.BiFunction; + +@RequiredArgsConstructor +public class RecommendCustomRepositoryImpl implements RecommendCustomRepository{ + + private final JPAQueryFactory jpaQueryFactory; + private final QMember qMember = QMember.member; + private final QMemberRecommendationLike qMemberRecommendationLike = QMemberRecommendationLike.memberRecommendationLike; + private final QRecommendation qRecommendation = QRecommendation.recommendation; + @Override + public RecommendResponses findMyRecommendations(Member member, Integer size, Long cursor) { + BooleanBuilder predicate = new BooleanBuilder(); + + if(cursor != null){ + predicate.and(qRecommendation.id.lt(cursor)); + } + + List recommendResponseList = jpaQueryFactory + .select(new QRecommendResponse( + qMember.id, + qMember.nickname, + qRecommendation.id, + qRecommendation.recommendType, + qMemberRecommendationLike.isNotNull(), + qRecommendation.title, + qRecommendation.content, + qRecommendation.imageUrl + )) + .from(qRecommendation) + .leftJoin(qRecommendation.member, qMember) + .leftJoin(qMemberRecommendationLike) + .on(qMemberRecommendationLike.member.eq(member).and(qMemberRecommendationLike.recommendation.eq(qRecommendation))) + .where(qRecommendation.member.eq(member) + .and(predicate)) + .orderBy(qRecommendation.id.desc()) + .limit(size+1) + .fetch(); + + return RecommendResponses.of(hasNext(recommendResponseList, size), + recommendResponseList.subList(0, Math.min(size, recommendResponseList.size()))); + } + + private boolean hasNext(List list, int size) { + return list.size() > size; + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/repository/RecommendationRepository.java b/src/main/java/com/example/mody/domain/recommendation/repository/RecommendationRepository.java new file mode 100644 index 00000000..0f9f8d84 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/repository/RecommendationRepository.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.recommendation.repository; + +import com.example.mody.domain.recommendation.entity.Recommendation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecommendationRepository extends JpaRepository, RecommendCustomRepository { +} diff --git a/src/main/java/com/example/mody/domain/recommendation/repository/category/AppealCategoryRepository.java b/src/main/java/com/example/mody/domain/recommendation/repository/category/AppealCategoryRepository.java new file mode 100644 index 00000000..996407b7 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/repository/category/AppealCategoryRepository.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.recommendation.repository.category; + +import com.example.mody.domain.recommendation.entity.category.AppealCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AppealCategoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/mody/domain/recommendation/repository/category/StyleCategoryRepository.java b/src/main/java/com/example/mody/domain/recommendation/repository/category/StyleCategoryRepository.java new file mode 100644 index 00000000..f2a43304 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/repository/category/StyleCategoryRepository.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.recommendation.repository.category; + +import com.example.mody.domain.recommendation.entity.category.StyleCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StyleCategoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/mody/domain/recommendation/service/CrawlerService.java b/src/main/java/com/example/mody/domain/recommendation/service/CrawlerService.java new file mode 100644 index 00000000..fb4c1b6f --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/service/CrawlerService.java @@ -0,0 +1,80 @@ +package com.example.mody.domain.recommendation.service; + +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.CrawlerErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.openqa.selenium.*; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; +import org.springframework.stereotype.Service; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CrawlerService { + + public String getRandomImageUrl(String keyword) { + + WebDriver driver = getWebDriver(); + WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(30)); + + try { + String searchUrl = "https://kr.pinterest.com/search/pins/?q=" + URLEncoder.encode(keyword, StandardCharsets.UTF_8); + driver.navigate().to(searchUrl); + log.info("Pinterest 검색 URL 접속 완료: {}", searchUrl); + + // 이미지 태그 검색 결과 로드 + wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//img[contains(@src, 'https')]"))); + log.info("이미지 태그 검색 결과 로드 완료"); + + Set imageUrls = new HashSet<>(); + List images = driver.findElements(By.xpath("//img[contains(@src, 'https')]")); + + int maxImages = Math.min(images.size(), 7); // 최대 이미지 7개(핀터레스트 첫 줄 사진이 7개) + for (int i = 0; i < maxImages; i++) { + String url = images.get(i).getAttribute("src"); + if (url != null && !url.isEmpty()) { + imageUrls.add(url); + } + } + + if (imageUrls.isEmpty()) { + log.error("이미지 URL을 찾을 수 없음 (키워드: {})", keyword); + throw new RestApiException(CrawlerErrorStatus.IMAGE_NOT_FOUND); + } + + List imageList = new ArrayList<>(imageUrls); + Collections.shuffle(imageList); + return imageList.get(0); + + } catch (TimeoutException e) { + log.error("페이지 로드 실패 (키워드: {})", keyword); + throw new RestApiException(CrawlerErrorStatus.PAGE_LOAD_ERROR); + } catch (Exception e) { + log.error("크롤링 실패 (키워드: {}):", keyword); + throw new RestApiException(CrawlerErrorStatus.CRAWLING_FAILED); + } finally { + driver.quit(); + } + } + + private WebDriver getWebDriver() { + ChromeOptions options = new ChromeOptions(); + options.addArguments("--headless"); // 백그라운드 실행 (UI 렌더링 생략) + options.addArguments("--disable-gpu"); // GPU 사용 X + options.addArguments("--no-sandbox"); // 샌드박스 모드 비활성화(Docker 환경에서 크롬 드라이버 실행에 필요) + options.addArguments("--disable-dev-shm-usage"); // /dev/shm 사용 비활성화(Docker 환경에서 크롬 크래시 문제 해결) + options.addArguments("--ignore-ssl-errors=yes"); + options.addArguments("--ignore-certificate-errors"); // SSL 차단 대비 + + return new ChromeDriver(options); + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/service/RecommendationCommendService.java b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationCommendService.java new file mode 100644 index 00000000..a9ee34ed --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationCommendService.java @@ -0,0 +1,15 @@ +package com.example.mody.domain.recommendation.service; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.dto.request.RecommendRequest; +import com.example.mody.domain.recommendation.dto.response.RecommendResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendLikeResponse; + +public interface RecommendationCommendService { + + RecommendResponse recommendStyle(Member member, RecommendRequest request); + + RecommendResponse recommendFashionItem(Member member, RecommendRequest request); + + RecommendLikeResponse toggleLike(Long recommendationId, Member member); +} diff --git a/src/main/java/com/example/mody/domain/recommendation/service/RecommendationCommendServiceImpl.java b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationCommendServiceImpl.java new file mode 100644 index 00000000..af14c7aa --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationCommendServiceImpl.java @@ -0,0 +1,186 @@ +package com.example.mody.domain.recommendation.service; + +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository; +import com.example.mody.domain.bodytype.service.memberbodytype.MemberBodyTypeQueryService; +import com.example.mody.domain.chatgpt.service.ChatGptService; +import com.example.mody.domain.exception.BodyTypeException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.dto.request.MemberInfoRequest; +import com.example.mody.domain.recommendation.dto.request.RecommendRequest; +import com.example.mody.domain.recommendation.dto.response.RecommendResponse; +import com.example.mody.domain.recommendation.dto.response.analysis.ItemAnalysisResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendLikeResponse; +import com.example.mody.domain.recommendation.dto.response.analysis.StyleAnalysisResponse; +import com.example.mody.domain.recommendation.entity.Recommendation; +import com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike; +import com.example.mody.domain.recommendation.enums.RecommendType; +import com.example.mody.domain.recommendation.repository.MemberRecommendationLikeRepository; +import com.example.mody.domain.recommendation.repository.RecommendationRepository; +import com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@Transactional +@RequiredArgsConstructor +public class RecommendationCommendServiceImpl implements RecommendationCommendService { + + private final MemberBodyTypeRepository memberBodyTypeRepository; + private final ChatGptService chatGptService; + private final ObjectMapper objectMapper; + private final RecommendationRepository recommendationRepository; + private final RecommendationQueryService recommendationQueryService; + private final MemberRecommendationLikeRepository memberRecommendationLikeRepository; + private final MemberBodyTypeQueryService memberBodyTypeQueryService; + + @Override + public RecommendResponse recommendStyle(Member member, RecommendRequest request) { + + //현재 유저의 bodyType 정보를 받아오기 + MemberBodyType latestBodyType = memberBodyTypeQueryService.getMemberBodyType(member); + + //gpt 추천 요청을 위한 사용자 정보 구성 + MemberInfoRequest memberInfoRequest = MemberInfoRequest.of( + member.getNickname(), + member.getGender(), + latestBodyType.getBody(), + latestBodyType.getBodyType().getName()); + + //gpt 추천 정보를 받아와 json content를 구성 + StyleAnalysisResponse analysisResponse = chatGptService.recommendGptStyle(memberInfoRequest, request); + String contentJson = convertToJson(analysisResponse); + Recommendation newRecommendation = Recommendation.of( + RecommendType.STYLE, + analysisResponse.getRecommendedStyle(), + contentJson, + analysisResponse.getImageUrl(), + member + ); + + //추천 데이터 저장 + Recommendation recommendation = recommendationRepository.save(newRecommendation); + + return RecommendResponse.of( + member.getId(), + member.getNickname(), + recommendation.getId(), + RecommendType.STYLE, + analysisResponse.getRecommendedStyle(), + contentJson, + analysisResponse.getImageUrl() + ); + } + + @Override + public RecommendResponse recommendFashionItem(Member member, RecommendRequest request) { + + //현재 유저의 bodyType 정보를 받아오기 + MemberBodyType latestBodyType = memberBodyTypeQueryService.getMemberBodyType(member); + + //gpt 추천 요청을 위한 사용자 정보 구성 + MemberInfoRequest memberInfoRequest = MemberInfoRequest.of( + member.getNickname(), + member.getGender(), + latestBodyType.getBody(), + latestBodyType.getBodyType().getName()); + + //gpt 추천 결과 받아옴 + ItemAnalysisResponse analysisResponse = chatGptService.recommendGptItem(memberInfoRequest, request); + + Recommendation newRecommendation = Recommendation.of( + RecommendType.FASHION_ITEM, + analysisResponse.getItem(), + analysisResponse.getDescription(), + analysisResponse.getImageUrl(), + member + ); + + //추천 결과 저장 + Recommendation recommendation = recommendationRepository.save(newRecommendation); + + return RecommendResponse.of( + member.getId(), + member.getNickname(), + recommendation.getId(), + RecommendType.FASHION_ITEM, + analysisResponse.getItem(), + analysisResponse.getDescription(), + analysisResponse.getImageUrl() + ); + } + + @Override + public RecommendLikeResponse toggleLike(Long recommendationId, Member member) { + Recommendation recommendation = recommendationQueryService.findById(recommendationId); + Optional existingLike = memberRecommendationLikeRepository.findTopByMemberAndRecommendation(member, recommendation); + + RecommendLikeResponse response = RecommendLikeResponse.builder() + .recommendId(recommendationId) + .memberId(member.getId()) + .build(); + + if(existingLike.isPresent()){ + deleteLike(existingLike.get(), recommendation); + response.setLiked(false); + } else { + addLike(member, recommendation); + response.setLiked(true); + } + return response; + } + + private void addLike(Member member, Recommendation recommendation){ + MemberRecommendationLike newMemberRecommendationLike = new MemberRecommendationLike(member, recommendation); + memberRecommendationLikeRepository.save(newMemberRecommendationLike); + increaseLikeCount(recommendation); + } + + private void increaseLikeCount(Recommendation recommendation){ + recommendation.increaseLikeCount(); + recommendation.getMember() + .increaseLikeCount(); + } + + private void deleteLike(MemberRecommendationLike memberRecommendationLike, Recommendation recommendation){ + memberRecommendationLikeRepository.delete(memberRecommendationLike); + decreaseLikeCount(recommendation); + } + + private void decreaseLikeCount(Recommendation recommendation){ + recommendation.decreaseLikeCount(); + recommendation.getMember() + .decreaseLikeCount(); + } + + private String convertToJson(StyleAnalysisResponse analysisResponse) { + try { + // JSON으로 변환할 데이터 구조 + ContentJson contentJson = new ContentJson( + analysisResponse.getIntroduction(), + analysisResponse.getStyleDirection(), + analysisResponse.getPracticalStylingTips() + ); + return objectMapper.writeValueAsString(contentJson); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 변환 오류", e); + } + } + + private static class ContentJson { + public String introduction; + public String styleDirection; + public String practicalStylingTips; + + public ContentJson(String introduction, String styleDirection, String practicalStylingTips) { + this.introduction = introduction; + this.styleDirection = styleDirection; + this.practicalStylingTips = practicalStylingTips; + } + } +} diff --git a/src/main/java/com/example/mody/domain/recommendation/service/RecommendationQueryService.java b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationQueryService.java new file mode 100644 index 00000000..1b90b09f --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationQueryService.java @@ -0,0 +1,15 @@ +package com.example.mody.domain.recommendation.service; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.dto.response.RecommendResponses; +import com.example.mody.domain.recommendation.dto.response.CategoryResponse; +import com.example.mody.domain.recommendation.entity.Recommendation; + +public interface RecommendationQueryService { + + CategoryResponse getCategories(); + + RecommendResponses getRecommendations(Member member, Integer size, Long cursor); + + Recommendation findById(Long recommendationId); +} diff --git a/src/main/java/com/example/mody/domain/recommendation/service/RecommendationQueryServiceImpl.java b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationQueryServiceImpl.java new file mode 100644 index 00000000..872f5d07 --- /dev/null +++ b/src/main/java/com/example/mody/domain/recommendation/service/RecommendationQueryServiceImpl.java @@ -0,0 +1,67 @@ +package com.example.mody.domain.recommendation.service; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.recommendation.dto.response.CategoryResponse; +import com.example.mody.domain.recommendation.dto.response.RecommendResponses; +import com.example.mody.domain.recommendation.entity.Recommendation; +import com.example.mody.domain.recommendation.exception.RecommendationException; +import com.example.mody.domain.recommendation.repository.category.AppealCategoryRepository; +import com.example.mody.domain.recommendation.repository.RecommendationRepository; +import com.example.mody.domain.recommendation.repository.category.StyleCategoryRepository; +import com.example.mody.global.common.exception.code.status.RecommendationErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +public class RecommendationQueryServiceImpl implements RecommendationQueryService{ + + private final StyleCategoryRepository styleCategoryRepository; + private final AppealCategoryRepository appealCategoryRepository; + private final RecommendationRepository recommendationRepository; + + @Override + public CategoryResponse getCategories() { + //styleCategories 조회 및 이름 리스트로 반환 + List styleCategories = styleCategoryRepository.findAll() + .stream() + .map(styleCategory -> styleCategory.getName()) + .collect(Collectors.toList()); + + if (styleCategories.isEmpty()) { + throw new RecommendationException(RecommendationErrorStatus.STYLE_CATEGORY_EMPTY); + } + + List appealCategories = appealCategoryRepository.findAll() + .stream() + .map(appealCategory -> appealCategory.getName()) + .collect(Collectors.toList()); + + if (appealCategories.isEmpty()) { + throw new RecommendationException(RecommendationErrorStatus.APPEAL_CATEGORY_EMPTY); + } + + return new CategoryResponse(styleCategories, appealCategories); + } + + @Override + public RecommendResponses getRecommendations(Member member, Integer size, Long cursor) { + RecommendResponses responses = recommendationRepository.findMyRecommendations(member, size, cursor); + + if (responses.getRecommendResponseList().isEmpty()) { + throw new RecommendationException(RecommendationErrorStatus.RECOMMENDATION_NOT_FOUND); + } + + return responses; + } + + @Override + public Recommendation findById(Long recommendationId) { + return recommendationRepository.findById(recommendationId).orElseThrow(() -> + new RecommendationException(RecommendationErrorStatus.RECOMMENDATION_NOT_FOUND)); + } +} diff --git a/src/main/java/com/example/mody/global/common/base/BaseResponse.java b/src/main/java/com/example/mody/global/common/base/BaseResponse.java index db9b71c0..1caa7af2 100644 --- a/src/main/java/com/example/mody/global/common/base/BaseResponse.java +++ b/src/main/java/com/example/mody/global/common/base/BaseResponse.java @@ -1,5 +1,6 @@ package com.example.mody.global.common.base; +import com.example.mody.global.common.exception.code.BaseCodeInterface; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import lombok.AllArgsConstructor; @@ -24,6 +25,18 @@ public static BaseResponse onSuccess(T result) { return new BaseResponse<>("COMMON200", "요청에 성공하였습니다.", result); } + public static BaseResponse onSuccessCreate(T result) { + return new BaseResponse<>("COMMON201", "요청에 성공하였습니다.", result); + } + + public static BaseResponse onSuccessDelete(T result) { + return new BaseResponse<>("COMMON202", "삭제 요청에 성공하였습니다.", result); + } + + public static BaseResponse of(BaseCodeInterface code, T result) { + return new BaseResponse<>(code.getCode().getCode(), code.getCode().getMessage(), result); + } + // 실패한 경우 응답 생성 public static BaseResponse onFailure(String code, String message, T data) { return new BaseResponse<>(code, message, data); diff --git a/src/main/java/com/example/mody/global/common/exception/ErrorSender.java b/src/main/java/com/example/mody/global/common/exception/ErrorSender.java new file mode 100644 index 00000000..97c37187 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/ErrorSender.java @@ -0,0 +1,115 @@ +package com.example.mody.global.common.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.reactive.function.client.WebClient; + +import java.io.PrintWriter; +import java.io.StringWriter; + +@RestControllerAdvice +public class ErrorSender { + + private static final Logger logger = LoggerFactory.getLogger(ErrorSender.class); + + private final WebClient webClient; + + @Value("${spring.profiles.active}") + private String activeProfile; // 현재 프로파일 + + @Value("${discord.webhook.url}") + private final String discordWebhookUrl; + + // Webhook URL은 application.yml 또는 application.properties에서 주입 + public ErrorSender(@Value("${discord.webhook.url}") String discordWebhookUrl, WebClient.Builder webClientBuilder) { + this.discordWebhookUrl = discordWebhookUrl; + this.webClient = webClientBuilder.build(); + } + + // @ExceptionHandler(Exception.class) +// public ResponseEntity handleException(Exception e) { +// +// // 에러 메시지 생성 +// String detailedMessage = buildDetailedErrorMessage(e); +// +// logger.error("An error occurred: {}", e.getMessage(), e); +// +// // 디스코드 알림 전송 +// sendErrorToDiscord(detailedMessage); +// +// return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); +// } +// + public void sendError(Exception e) { + + // 로컬 프로파일인 경우 알림 생략 + if ("local".equals(activeProfile)) { + return; + } + + String detailedMessage = buildDetailedErrorMessage(e); + + logger.error("An error occurred: {}", e.getMessage(), e); + + // 디스코드 알림 전송 + sendErrorToDiscord(detailedMessage); + + } + + + + private String buildDetailedErrorMessage(Exception e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); // 스택 트레이스를 문자열로 변환 + + String stackTrace = sw.toString(); + if (stackTrace.length() > 1500) { // 메시지 크기 제한 + stackTrace = stackTrace.substring(0, 1500) + "\n... (stack trace truncated)"; + } + + return String.format( + "**[Error Alert]**\n" + + "💥 *An error occurred in the application.*\n\n" + + "**Message:** `%s`\n" + + "**Exception Type:** `%s`\n" + + "**Stack Trace:**\n```\n%s\n```", + escapeMarkdown(e.getMessage()), + escapeMarkdown(e.getClass().getName()), + escapeMarkdown(stackTrace) + ); + } + + private String escapeMarkdown(String text) { + if (text == null) return "메세지가 존재하지 않음"; + return text.replaceAll("`", "\\\\`") + .replaceAll("\\*", "\\\\*") + .replaceAll("_", "\\\\_") + .replaceAll("~", "\\\\~") + .replaceAll("\\|", "\\\\|"); + } + + private void sendErrorToDiscord(String message) { + try { + logger.debug("Discord로 메시지 보냄: {}", message); + + // WebClient를 사용하여 POST 요청을 보내고, 응답을 무시 + webClient.post() + .uri(discordWebhookUrl) + .bodyValue(new DiscordMessage(message)) + .retrieve() + .toBodilessEntity() + .doOnSuccess(response -> logger.info("Discord alert sent successfully")) + .doOnError(error -> logger.error("Failed to send Discord alert: {}", error.getMessage())) + .block(); + } catch (Exception ex) { + logger.error("Failed to send error to Discord: {}", ex.getMessage()); + } + } + + // 디스코드 메시지에 사용할 간단한 클래스 + private record DiscordMessage(String content) { + + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/common/exception/ExceptionAdvice.java b/src/main/java/com/example/mody/global/common/exception/ExceptionAdvice.java index 7839c878..cff5db6c 100644 --- a/src/main/java/com/example/mody/global/common/exception/ExceptionAdvice.java +++ b/src/main/java/com/example/mody/global/common/exception/ExceptionAdvice.java @@ -3,11 +3,14 @@ import com.example.mody.global.common.base.BaseResponse; import com.example.mody.global.common.exception.code.BaseCodeDto; import com.example.mody.global.common.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestController; @@ -22,7 +25,11 @@ @Slf4j @RestControllerAdvice(annotations = {RestController.class}) +@RequiredArgsConstructor public class ExceptionAdvice extends ResponseEntityExceptionHandler { + + private final ErrorSender errorSender; + /* * 직접 정의한 RestApiException 에러 클래스에 대한 예외 처리 */ @@ -30,6 +37,7 @@ public class ExceptionAdvice extends ResponseEntityExceptionHandler { @ExceptionHandler(value = RestApiException.class) public ResponseEntity> handleRestApiException(RestApiException e) { BaseCodeDto errorCode = e.getErrorCode(); + log.error("An error occurred: {}", e.getMessage(), e); return handleExceptionInternal(errorCode); } @@ -38,8 +46,8 @@ public ResponseEntity> handleRestApiException(RestApiExcept */ @ExceptionHandler public ResponseEntity> handleException(Exception e) { - e.printStackTrace(); //예외 정보 출력 - + log.error("An error occurred: {}", e.getMessage(), e); + errorSender.sendError(e); return handleExceptionInternalFalse(GlobalErrorStatus._INTERNAL_SERVER_ERROR.getCode(), e.getMessage()); } @@ -49,6 +57,8 @@ public ResponseEntity> handleException(Exception e) { */ @ExceptionHandler public ResponseEntity> handleConstraintViolationException(ConstraintViolationException e) { + errorSender.sendError(e); + log.error("An error occurred: {}", e.getMessage(), e); return handleExceptionInternal(GlobalErrorStatus._VALIDATION_ERROR.getCode()); } @@ -59,6 +69,7 @@ public ResponseEntity> handleConstraintViolationException(C @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity> handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException e) { // 예외 처리 로직 + log.error("An error occurred: {}", e.getMessage(), e); return handleExceptionInternal(GlobalErrorStatus._METHOD_ARGUMENT_ERROR.getCode()); } @@ -78,10 +89,25 @@ public ResponseEntity handleMethodArgumentNotValid( errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); }); + log.error("An error occurred: {}", e.getMessage(), e); + return handleExceptionInternalArgs(GlobalErrorStatus._VALIDATION_ERROR.getCode(), errors); } + // 인증되지 않은 사용자에 대한 처리 + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthenticationException(AuthenticationException e) { + return handleExceptionInternal(GlobalErrorStatus._UNAUTHORIZED.getCode()); + } + + // 권한이 없는 사용자에 대한 처리 + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException e) { + return handleExceptionInternal(GlobalErrorStatus._ACCESS_DENIED.getCode()); + } + + private ResponseEntity> handleExceptionInternal(BaseCodeDto errorCode) { return ResponseEntity .status(errorCode.getHttpStatus().value()) diff --git a/src/main/java/com/example/mody/global/common/exception/annotation/IsEmptyList.java b/src/main/java/com/example/mody/global/common/exception/annotation/IsEmptyList.java new file mode 100644 index 00000000..1ac31713 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/annotation/IsEmptyList.java @@ -0,0 +1,22 @@ +package com.example.mody.global.common.exception.annotation; + +import com.example.mody.global.common.exception.validator.NotEmptyListValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = NotEmptyListValidator.class) +public @interface IsEmptyList { + + String message() default "리스트는 비어 있을 수 없습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/AnalysisErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/AnalysisErrorStatus.java new file mode 100644 index 00000000..f5086cc4 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/AnalysisErrorStatus.java @@ -0,0 +1,37 @@ +package com.example.mody.global.common.exception.code.status; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AnalysisErrorStatus implements BaseCodeInterface { + _NOT_YET(HttpStatus.OK, "ANALYSIS001", "아직 분석 중입니다."), + _INCLUDE_INAPPROPRIATE_CONTENT(HttpStatus.OK, "ANALYSIS002", "부적절한 내용이 포함되어 있습니다."), + _NOT_READ_VOICE(HttpStatus.OK, "ANALYSIS003", "텍스트를 그대로 읽지 않았습니다."), + _ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "ANALYSIS004", "분석 중 에러가 발생하였습니다."), + _CANNOT_SAVE_ANALYSIS_RESULT(HttpStatus.INTERNAL_SERVER_ERROR, "ANALYSIS005", "분석 결과 저장에 문제가 발생했습니다."), + _ANALYSIS_NOT_YET(HttpStatus.OK, "ANALYSIS006", "분석 중이지 않습니다."), + _DENIED_BY_GPT(HttpStatus.OK, "ANALYSIS107", "GPT: 올바르지 않은 스크립트입니다."), + _GPT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "ANALYSIS108", "GPT가 올바르지 않은 답변을 했습니다. 관리자에게 문의하세요."),; + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java index 2ce1a6e5..c63dfbf1 100644 --- a/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java +++ b/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java @@ -1,42 +1,47 @@ package com.example.mody.global.common.exception.code.status; +import org.springframework.http.HttpStatus; + import com.example.mody.global.common.exception.code.BaseCodeDto; import com.example.mody.global.common.exception.code.BaseCodeInterface; + import lombok.AllArgsConstructor; import lombok.Getter; -import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum AuthErrorStatus implements BaseCodeInterface { - EMPTY_JWT(HttpStatus.UNAUTHORIZED, "AUTH001", "JWT가 없습니다."), - EXPIRED_MEMBER_JWT(HttpStatus.UNAUTHORIZED, "AUTH002", "만료된 JWT입니다."), - UNSUPPORTED_JWT(HttpStatus.UNAUTHORIZED, "AUTH003", "지원하지 않는 JWT입니다."), - - INVALID_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH004", "유효하지 않은 ID TOKEN입니다."), - EXPIRED_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH005", "만료된 REFRESH TOKEN입니다."), - INVALID_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "AUTH005", "유효하지 않은 ACCESS TOKEN입니다."), - INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH006", "유효하지 않은 REFRESH TOKEN입니다."), - FAILED_SOCIAL_LOGIN(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH007", "소셜 로그인에 실패하였습니다."), - - FAILED_GITHUB_AUTHENTICATION(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH008", "깃허브 서버와 통신이 실패하였습니다."), - FAILED_GET_APPLE_KEY(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH009", "애플 서버와 통신이 실패하였습니다."), - INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH010","유효하지 않은 애플 ID TOKEN입니다."), - ; - - private final HttpStatus httpStatus; - private final boolean isSuccess = false; - private final String code; - private final String message; - - @Override - public BaseCodeDto getCode() { - return BaseCodeDto.builder() - .httpStatus(httpStatus) - .isSuccess(isSuccess) - .code(code) - .message(message) - .build(); - } + EMPTY_JWT(HttpStatus.UNAUTHORIZED, "AUTH401", "JWT가 없습니다."), + EXPIRED_MEMBER_JWT(HttpStatus.UNAUTHORIZED, "TOKEN402", "JWT가 만료되었습니다."), + UNSUPPORTED_JWT(HttpStatus.UNAUTHORIZED, "TOKEN403", "지원하지 않는 JWT입니다."), + INVALID_ID_TOKEN(HttpStatus.BAD_REQUEST, "TOKEN401", "유효하지 않은 ID TOKEN입니다."), + + EXPIRED_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "REFRESH_TOKEN401", "REFRESH TOKEN이 만료되었습니다."), + INVALID_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "ACCESS_TOKEN404", "ACCESS TOKEN이 유효하지 않습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "REFRESH_TOKEN404", "REFRESH TOKEN이 유효하지 않습니다."), + FAILED_SOCIAL_LOGIN(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH007", "소셜 로그인에 실패하였습니다."), + + FAILED_GITHUB_AUTHENTICATION(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH008", "깃허브 서버와 통신이 실패하였습니다."), + FAILED_GET_APPLE_KEY(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH009", "애플 서버와 통신이 실패하였습니다."), + INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH010", "유효하지 않은 애플 ID TOKEN입니다."), + + // Email Error + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH011", "유효하지 않은 인증 코드입니다."), + ; + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } } diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/BodyTypeErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/BodyTypeErrorStatus.java new file mode 100644 index 00000000..d55058c6 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/BodyTypeErrorStatus.java @@ -0,0 +1,32 @@ +package com.example.mody.global.common.exception.code.status; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum BodyTypeErrorStatus implements BaseCodeInterface { + + MEMBER_BODY_TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_BODY_TYPE404", "체형 분석 결과를 찾을 수 없습니다."), + BODY_TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "BODY_TYPE404", "체형을 찾을 수 없습니다."), + JSON_PARSING_ERROR(HttpStatus.BAD_REQUEST, "JSON_PARSING400", "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다."), + ; + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/CrawlerErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/CrawlerErrorStatus.java new file mode 100644 index 00000000..ebcad861 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/CrawlerErrorStatus.java @@ -0,0 +1,42 @@ +package com.example.mody.global.common.exception.code.status; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum CrawlerErrorStatus implements BaseCodeInterface { + PAGE_LOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "PAGE500", "페이지를 불러오는 데 실패했습니다."), + + SEARCH_EXECUTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SEARCH500", "검색 실행 중 오류가 발생했습니다."), + + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "검색 결과에서 이미지를 찾을 수 없습니다."), + + CHROME_DRIVER_VERSION_MISMATCH(HttpStatus.INTERNAL_SERVER_ERROR, "CHROME500", "ChromeDriver와 Chrome 브라우저 버전이 일치하지 않습니다."), + + SELENIUM_BLOCKED(HttpStatus.FORBIDDEN, "SELENIUM403", "Pinterest에서 Selenium 사용이 감지되었습니다. (크롤링 차단 가능성)"), + + CRAWLING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CRAWLING500", "크롤링 중 알 수 없는 오류가 발생했습니다."), + ; + + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/FashionItemErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/FashionItemErrorStatus.java new file mode 100644 index 00000000..aff97240 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/FashionItemErrorStatus.java @@ -0,0 +1,29 @@ +package com.example.mody.global.common.exception.code.status; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum FashionItemErrorStatus implements BaseCodeInterface { + + ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "ITEM404", "해당 패션 아이템을 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/GlobalErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/GlobalErrorStatus.java index ce05663e..a6b99d52 100644 --- a/src/main/java/com/example/mody/global/common/exception/code/status/GlobalErrorStatus.java +++ b/src/main/java/com/example/mody/global/common/exception/code/status/GlobalErrorStatus.java @@ -18,6 +18,7 @@ public enum GlobalErrorStatus implements BaseCodeInterface { _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "요청한 정보를 찾을 수 없습니다."), _METHOD_ARGUMENT_ERROR(HttpStatus.BAD_REQUEST, "COMMON405", "Argument Type이 올바르지 않습니다."), _INTERNAL_PAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "페이지 에러, 0 이상의 페이지를 입력해주세요"), + _ACCESS_DENIED(HttpStatus.FORBIDDEN, "COMMON403", "접근 권한이 없습니다."), // S3 관련 에러 _S3_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3_5001", "파일 업로드에 실패했습니다."), @@ -26,6 +27,9 @@ public enum GlobalErrorStatus implements BaseCodeInterface { // For test TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "예외처리 테스트입니다."), + + //페이지 관련 오류 + NEGATIVE_PAGE_SIZE_REQUEST(HttpStatus.BAD_REQUEST, "PAGE4001", "잘못된 페이지 사이즈 요청입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/MemberErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/MemberErrorStatus.java new file mode 100644 index 00000000..26f6bb8d --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/MemberErrorStatus.java @@ -0,0 +1,33 @@ +package com.example.mody.global.common.exception.code.status; + +import org.springframework.http.HttpStatus; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberErrorStatus implements BaseCodeInterface { + + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", "해당 회원을 찾을 수 없습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "EMAIL409", "이미 존재하는 이메일입니다."), + INVALID_LOGIN_TYPE(HttpStatus.BAD_REQUEST, "MEMBER400", "잘못된 로그인 방식입니다."); + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/MemberPostLikeErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/MemberPostLikeErrorStatus.java new file mode 100644 index 00000000..e5a88901 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/MemberPostLikeErrorStatus.java @@ -0,0 +1,30 @@ +package com.example.mody.global.common.exception.code.status; + +import ch.qos.logback.core.spi.ErrorCodes; +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MemberPostLikeErrorStatus implements BaseCodeInterface { + LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_POST_LIKE404", "해당 좋아요를 찾을 수 없습니다.") + ; + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/PostErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/PostErrorStatus.java new file mode 100644 index 00000000..93cc96ee --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/PostErrorStatus.java @@ -0,0 +1,34 @@ +package com.example.mody.global.common.exception.code.status; + +import org.springframework.http.HttpStatus; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PostErrorStatus implements BaseCodeInterface { + + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST404", "해당 게시글을 찾을 수 없습니다."), + POST_FORBIDDEN(HttpStatus.FORBIDDEN, "POST403", "해당 게시글에 대한 권한이 없습니다."), + POST_ALREADY_REPORT(HttpStatus.BAD_REQUEST, "POST409", "이미 신고한 게시글 입니다."), + ; + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/RecommendationErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/RecommendationErrorStatus.java new file mode 100644 index 00000000..1d995447 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/RecommendationErrorStatus.java @@ -0,0 +1,34 @@ +package com.example.mody.global.common.exception.code.status; + +import org.springframework.http.HttpStatus; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RecommendationErrorStatus implements BaseCodeInterface { + + RECOMMENDATION_NOT_FOUND(HttpStatus.NOT_FOUND, "RECOMMENDATION404", "해당 추천 결과를 찾을 수 없습니다."), + STYLE_CATEGORY_EMPTY(HttpStatus.BAD_REQUEST, "RECOMMENDATION401", "데이터베이스에 스타일 카테고리가 입력되어 있지 않습니다."), + APPEAL_CATEGORY_EMPTY(HttpStatus.NOT_FOUND, "RECOMMENDATION402", "테이터베이스에 어필 카테고리가 입력되어 있지 않습니다."); + + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/S3ErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/S3ErrorStatus.java new file mode 100644 index 00000000..db276222 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/S3ErrorStatus.java @@ -0,0 +1,56 @@ +package com.example.mody.global.common.exception.code.status; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum S3ErrorStatus implements BaseCodeInterface { + + // 버킷 관련 오류 + BUCKET_NOT_FOUND(HttpStatus.NOT_FOUND, "S3_404", "지정된 S3 버킷을 찾을 수 없습니다."), + BUCKET_ACCESS_DENIED(HttpStatus.FORBIDDEN, "S3_403", "S3 버킷에 대한 접근이 거부되었습니다."), + BUCKET_NAME_INVALID(HttpStatus.BAD_REQUEST, "S3_400", "S3 버킷 이름이 유효하지 않습니다."), + BUCKET_REGION_MISMATCH(HttpStatus.BAD_REQUEST, "S3_400", "요청한 S3 버킷이 지정된 리전과 일치하지 않습니다."), + + // 객체(파일) 관련 오류 + OBJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "S3_404", "요청한 S3 객체를 찾을 수 없습니다."), + OBJECT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "S3_403", "S3 객체에 대한 접근이 거부되었습니다."), + OBJECT_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500", "S3 객체 업로드 중 오류가 발생했습니다."), + OBJECT_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500", "S3 객체 다운로드 중 오류가 발생했습니다."), + OBJECT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500", "S3 객체 삭제 중 오류가 발생했습니다."), + OBJECT_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "S3_400", "업로드할 파일 크기가 허용된 한도를 초과했습니다."), + OBJECT_INVALID_KEY(HttpStatus.BAD_REQUEST, "S3_400", "S3 객체 키가 유효하지 않습니다."), + + // presigned url 관련 오류 + PRESIGNED_URL_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500", "S3 presigned url 생성 중 오류가 발생했습니다."), + PRESIGNED_URL_EXPIRED(HttpStatus.BAD_REQUEST, "S3_400", "요청한 presigned url이 만료되었습니다."), + PRESIGNED_URL_INVALID(HttpStatus.BAD_REQUEST, "S3_400", "요청한 presigned url이 유효하지 않습니다."), + + // 인증 및 권한 관련 오류 + CREDENTIALS_INVALID(HttpStatus.UNAUTHORIZED, "S3_401", "S3 접근을 위한 자격 증명이 유효하지 않습니다."), + PERMISSION_DENIED(HttpStatus.FORBIDDEN, "S3_403", "S3 작업에 대한 권한이 부족합니다."), + + // 기타 S3 관련 오류 + NETWORK_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500", "S3 서비스와의 네트워크 연결에 문제가 발생했습니다."), + UNKNOWN_S3_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500", "알 수 없는 S3 오류가 발생했습니다."), + ; + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/StyleErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/StyleErrorStatus.java new file mode 100644 index 00000000..5a191770 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/StyleErrorStatus.java @@ -0,0 +1,34 @@ +package com.example.mody.global.common.exception.code.status; + +import org.springframework.http.HttpStatus; + +import com.example.mody.global.common.exception.code.BaseCodeDto; +import com.example.mody.global.common.exception.code.BaseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum StyleErrorStatus implements BaseCodeInterface { + + STYLE_NOT_FOUND(HttpStatus.NOT_FOUND, "STYLE404", "해당 스타일을 찾을 수 없습니다."), + STYLE_CATEGORY_EMPTY(HttpStatus.BAD_REQUEST, "STYLE401", "데이터베이스에 스타일 카테고리가 입력되어 있지 않습니다."), + APPEAL_CATEGORY_EMPTY(HttpStatus.NOT_FOUND, "STYLE402", "테이터베이스에 어필 카테고리가 입력되어 있지 않습니다."); + + + private final HttpStatus httpStatus; + private final boolean isSuccess = false; + private final String code; + private final String message; + + @Override + public BaseCodeDto getCode() { + return BaseCodeDto.builder() + .httpStatus(httpStatus) + .isSuccess(isSuccess) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/validator/NotEmptyListValidator.java b/src/main/java/com/example/mody/global/common/exception/validator/NotEmptyListValidator.java new file mode 100644 index 00000000..dff172c3 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/validator/NotEmptyListValidator.java @@ -0,0 +1,15 @@ +package com.example.mody.global.common.exception.validator; + +import com.example.mody.global.common.exception.annotation.IsEmptyList; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.List; + +public class NotEmptyListValidator implements ConstraintValidator> { + + @Override + public boolean isValid(List value, ConstraintValidatorContext context) { + return value != null && !value.isEmpty(); + } +} diff --git a/src/main/java/com/example/mody/global/config/CorsMvcConfig.java b/src/main/java/com/example/mody/global/config/CorsMvcConfig.java new file mode 100644 index 00000000..9e68017a --- /dev/null +++ b/src/main/java/com/example/mody/global/config/CorsMvcConfig.java @@ -0,0 +1,24 @@ +package com.example.mody.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry corsRegistry) { + corsRegistry.addMapping("/**") + .exposedHeaders("Set-Cookie") + .allowedOrigins( + "http://localhost:5173", // 현재 요청 URL + "https://kkoalla.app", + "https://kkoalla.app:5173", + "https://kkoalla.app:8443" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/config/OpenAiConfig.java b/src/main/java/com/example/mody/global/config/OpenAiConfig.java new file mode 100644 index 00000000..15a2f898 --- /dev/null +++ b/src/main/java/com/example/mody/global/config/OpenAiConfig.java @@ -0,0 +1,31 @@ +package com.example.mody.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * secret key와 url에 맞게 WebClient를 빈으로 등록 + */ +@Configuration +public class OpenAiConfig { + + @Value("${openai.secret-key}") + private String secretKey; + + // OpenAI API의 기본 URL + private static final String OPENAI_BASE_URL = "https://api.openai.com/v1"; + + // JSON 형태의 메타데이터와 secretKey 값을 넣은 공통 헤더 + @Bean + public WebClient webClient(WebClient.Builder webClientBuilder) { + return webClientBuilder + .baseUrl(OPENAI_BASE_URL) + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + secretKey) // jwt 토큰으로 Bearer 토큰 값을 입력하여 전송 + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/config/QueryDSLConfig.java b/src/main/java/com/example/mody/global/config/QueryDSLConfig.java index ab3e8fc1..72d34f5d 100644 --- a/src/main/java/com/example/mody/global/config/QueryDSLConfig.java +++ b/src/main/java/com/example/mody/global/config/QueryDSLConfig.java @@ -1,5 +1,6 @@ package com.example.mody.global.config; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -13,6 +14,6 @@ public class QueryDSLConfig { @Bean public JPAQueryFactory jpaQueryFactory() { - return new JPAQueryFactory(entityManager); + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); } } diff --git a/src/main/java/com/example/mody/global/config/RedisConfig.java b/src/main/java/com/example/mody/global/config/RedisConfig.java new file mode 100644 index 00000000..a07e1dbb --- /dev/null +++ b/src/main/java/com/example/mody/global/config/RedisConfig.java @@ -0,0 +1,44 @@ +package com.example.mody.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + // key, value 직렬화 방식을 StringRedisSerializer로 설정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + + @Bean + public StringRedisTemplate stringRedisTemplate() { + StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); + stringRedisTemplate.setConnectionFactory(redisConnectionFactory()); + return stringRedisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/config/RestTemplateConfig.java b/src/main/java/com/example/mody/global/config/RestTemplateConfig.java new file mode 100644 index 00000000..1b5d225f --- /dev/null +++ b/src/main/java/com/example/mody/global/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.example.mody.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/config/S3Config.java b/src/main/java/com/example/mody/global/config/S3Config.java index 8f1b7967..952d9612 100644 --- a/src/main/java/com/example/mody/global/config/S3Config.java +++ b/src/main/java/com/example/mody/global/config/S3Config.java @@ -1,13 +1,15 @@ package com.example.mody.global.config; +import com.amazonaws.auth.AWSCredentials; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import org.springframework.context.annotation.Bean; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; -//@Configuration +@Configuration public class S3Config { @Value("${cloud.aws.credentials.accessKey}") private String accessKey; @@ -19,11 +21,12 @@ public class S3Config { private String region; @Bean - public AmazonS3Client amazonS3Client() { - BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); - return (AmazonS3Client) AmazonS3ClientBuilder.standard() + public AmazonS3 amazonS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) .withRegion(region) - .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) .build(); } } diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java new file mode 100644 index 00000000..d2a89f38 --- /dev/null +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -0,0 +1,144 @@ +package com.example.mody.global.config; + +import java.util.Arrays; + +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.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.example.mody.domain.auth.handler.OAuth2SuccessHandler; +import com.example.mody.domain.auth.jwt.JwtAuthenticationFilter; +import com.example.mody.domain.auth.jwt.JwtLoginFilter; +import com.example.mody.domain.auth.jwt.JwtProvider; +import com.example.mody.domain.auth.security.OAuth2UserService; +import com.example.mody.domain.auth.service.AuthCommandService; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.domain.member.service.MemberQueryService; +import com.example.mody.global.util.CustomAuthenticationEntryPoint; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final OAuth2UserService oAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final JwtProvider jwtProvider; + private final MemberRepository memberRepository; + private final ObjectMapper objectMapper; + private final AuthenticationConfiguration authenticationConfiguration; + private final AuthCommandService authCommandService; + private final MemberQueryService memberQueryService; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // 기본 보안 설정 + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(httpBasic -> httpBasic.disable()) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/auth/**", "/oauth2/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .requestMatchers("/email/**").permitAll() + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(oAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + ) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(customAuthenticationEntryPoint) + ) + .cors(cors -> cors + .configurationSource(corsConfigurationSource()) + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + // JWT 로그인 필터 설정 + JwtLoginFilter jwtLoginFilter = new JwtLoginFilter( + authenticationManager(authenticationConfiguration), + jwtProvider, + authCommandService, + memberRepository, + objectMapper + ); + jwtLoginFilter.setFilterProcessesUrl("/auth/login"); + http.addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 허용할 Origin 설정 + configuration.setAllowedOrigins(Arrays.asList( + "https://kkoalla.app", // 프론트엔드 도메인 + "https://kkoalla.app:5173", // 개발 서버 + "http://localhost:5173", // 로컬 개발 + "https://kkoalla.app:8443" + )); + + // 허용할 HTTP 메서드 설정 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + // 허용할 헤더 설정 + configuration.setAllowedHeaders(Arrays.asList( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + "Access-Control-Allow-Origin" + )); + + // 인증 정보 포함 설정 + configuration.setAllowCredentials(true); + + // preflight 요청의 캐시 시간 설정 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtProvider, memberRepository, objectMapper, memberQueryService); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/example/mody/global/config/SwaggerConfig.java b/src/main/java/com/example/mody/global/config/SwaggerConfig.java index c50f67af..8f839d4d 100644 --- a/src/main/java/com/example/mody/global/config/SwaggerConfig.java +++ b/src/main/java/com/example/mody/global/config/SwaggerConfig.java @@ -1,40 +1,70 @@ package com.example.mody.global.config; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; @Configuration +@OpenAPIDefinition( + servers = { + @Server(url = "https://kkoalla.app:8443", description = "모디 https 개발 서버입니다."), + @Server(url = "http://3.37.4.11:8000", description = "모디 http 개발 서버입니다."), + @Server(url = "http://localhost:8080", description = "모디 local 서버입니다.") + } +) public class SwaggerConfig { - @Bean - public OpenAPI openAPI() { - - // SecuritySecheme명 - String jwtSchemeName = "Authorization"; - // API 요청헤더에 인증정보 포함 - SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); - // SecuritySchemes 등록 - Components components = new Components() - .addSecuritySchemes(jwtSchemeName, new SecurityScheme() - .name(jwtSchemeName) - .type(SecurityScheme.Type.HTTP) - .scheme("Bearer")); - - return new OpenAPI() - .addSecurityItem(securityRequirement) - .components(components) - .info(apiInfo()); - } + @Bean + public OpenAPI openAPI() { + String jwtSchemeName = "Authorization"; + // API 요청헤더에 인증정보 포함 + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + // SecuritySchemes 등록 + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat("JWT")); - private Info apiInfo() { - return new Info() - .title("GAJI REST API Specifications") - .description("GAJI API 명세서입니다.") - .version("1.0.0"); - } -} + return new OpenAPI() + .addSecurityItem(securityRequirement) + .components(components) + .info(new Info() + .title("MODY API Documentation") + .description(""" + # 소셜 로그인 플로우 + + ## 1. 카카오 로그인 + 1. 프론트엔드에서 카카오 로그인 버튼 클릭 + 2. `/oauth2/authorization/kakao` 엔드포인트로 리다이렉트 + 3. 카카오 로그인 페이지에서 인증 + 4. 인증 성공 시 `/oauth2/callback/kakao?code={code}` 로 리다이렉트 + 5. 서버에서 인증 코드로 카카오 토큰 발급 + 6. 카카오 토큰으로 사용자 정보 조회 + 7. DB에서 사용자 확인 후 처리: + - 기존 회원: JWT 토큰 발급 + - 신규 회원: 회원가입 페이지로 이동 + + ## 2. 응답 형식 + - Authorization 헤더: Bearer JWT 액세스 토큰 + - Set-Cookie: httpOnly secure 리프레시 토큰 + - Body: LoginResponse (회원 정보) + + ## 3. 토큰 갱신 + - 액세스 토큰 만료 시 `/auth/refresh` 호출 + - 리프레시 토큰으로 새로운 액세스 토큰 발급 + + ## 4. 로그아웃 + - `/auth/logout` 호출 + - 리프레시 토큰 삭제 및 쿠키 제거 + """) + .version("1.0.0")); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/config/ThymeleafConfig.java b/src/main/java/com/example/mody/global/config/ThymeleafConfig.java new file mode 100644 index 00000000..781cc240 --- /dev/null +++ b/src/main/java/com/example/mody/global/config/ThymeleafConfig.java @@ -0,0 +1,27 @@ +package com.example.mody.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver; + +@Configuration +public class ThymeleafConfig { + + @Bean + public SpringTemplateEngine templateEngine() { + SpringTemplateEngine templateEngine = new SpringTemplateEngine(); + templateEngine.setTemplateResolver(thymeleafTemplateResolver()); + return templateEngine; + } + + @Bean + public SpringResourceTemplateResolver thymeleafTemplateResolver() { + SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver(); + templateResolver.setPrefix("classpath:/templates/"); + templateResolver.setSuffix(".html"); + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + return templateResolver; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/dto/response/CursorPagination.java b/src/main/java/com/example/mody/global/dto/response/CursorPagination.java new file mode 100644 index 00000000..112ac92a --- /dev/null +++ b/src/main/java/com/example/mody/global/dto/response/CursorPagination.java @@ -0,0 +1,11 @@ +package com.example.mody.global.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CursorPagination { + private Boolean hasNext; + private Long cursor; +} diff --git a/src/main/java/com/example/mody/global/dto/response/CursorResult.java b/src/main/java/com/example/mody/global/dto/response/CursorResult.java new file mode 100644 index 00000000..6ae5790f --- /dev/null +++ b/src/main/java/com/example/mody/global/dto/response/CursorResult.java @@ -0,0 +1,12 @@ +package com.example.mody.global.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CursorResult { + + private T data; + private CursorPagination pagination; +} diff --git a/src/main/java/com/example/mody/global/infrastructure/ai/OpenAiApiClient.java b/src/main/java/com/example/mody/global/infrastructure/ai/OpenAiApiClient.java new file mode 100644 index 00000000..7fd25b48 --- /dev/null +++ b/src/main/java/com/example/mody/global/infrastructure/ai/OpenAiApiClient.java @@ -0,0 +1,34 @@ +package com.example.mody.global.infrastructure.ai; + +import com.example.mody.global.infrastructure.ai.dto.ChatGPTRequest; +import com.example.mody.global.infrastructure.ai.dto.ChatGPTResponse; +import com.example.mody.global.infrastructure.ai.dto.Message; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; + +/** + * WebClient를 사용하여 OpenAi Api와 통신. OpenAi Api와 통신하기 위한 필수적인 헤더들 추가하는 클래스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenAiApiClient { + + private final WebClient webClient; + + public ChatGPTResponse sendRequestToModel(String model, List messages, int maxTokens, double temperature) { + ChatGPTRequest request = new ChatGPTRequest(model, messages, maxTokens, temperature); + log.info("request: {}", request); + + return webClient.post() + .uri("/chat/completions") + .bodyValue(request) + .retrieve() + .bodyToMono(ChatGPTResponse.class) + .block(); // 동기식 처리 + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTRequest.java b/src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTRequest.java new file mode 100644 index 00000000..2595a411 --- /dev/null +++ b/src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTRequest.java @@ -0,0 +1,17 @@ +package com.example.mody.global.infrastructure.ai.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import java.util.List; + +/** + * OpenAI 요청 DTO + */ +@Data +@AllArgsConstructor +public class ChatGPTRequest { + private String model; + private List messages; + private int max_tokens; + private double temperature; +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTResponse.java b/src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTResponse.java new file mode 100644 index 00000000..c5e1f0b9 --- /dev/null +++ b/src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTResponse.java @@ -0,0 +1,25 @@ +package com.example.mody.global.infrastructure.ai.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * OpenAI 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatGPTResponse { + private List choices; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Choice { + private int index; + private Message message; + } +} diff --git a/src/main/java/com/example/mody/global/infrastructure/ai/dto/Message.java b/src/main/java/com/example/mody/global/infrastructure/ai/dto/Message.java new file mode 100644 index 00000000..d7f94503 --- /dev/null +++ b/src/main/java/com/example/mody/global/infrastructure/ai/dto/Message.java @@ -0,0 +1,16 @@ +package com.example.mody.global.infrastructure.ai.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 요청, 응답에서 사용하는 메시지 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Message { + private String role; // "system", "user", "assistant" + private String content; +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/global/templates/PromptManager.java b/src/main/java/com/example/mody/global/templates/PromptManager.java new file mode 100644 index 00000000..c8052a3b --- /dev/null +++ b/src/main/java/com/example/mody/global/templates/PromptManager.java @@ -0,0 +1,116 @@ +package com.example.mody.global.templates; + +import com.example.mody.domain.member.enums.Gender; +import com.example.mody.domain.recommendation.dto.request.MemberInfoRequest; +import com.example.mody.domain.recommendation.dto.request.RecommendRequest; +import org.springframework.stereotype.Component; + +/** + * PromptTemplate에 맞게 프롬프트를 생성, 관리하는 클래스 + */ +@Component +public class PromptManager { + + // 체형 분석 프롬프트 생성 메서드 + public String createBodyTypeAnalysisPrompt(String nickName, Gender gender, String answers) { + PromptTemplate template = new PromptTemplate(); + return template.fillTemplate( + """ + ## 명령 + 닉네임과 성별, 그리고 사용자의 답변을 기반으로 체형 타입(네추럴, 스트레이트, 웨이브 중 하나)을 분석하고, 설명과 스타일링 팁을 제공해줘. + 결과는 JSON 형식으로 반환해줘. + + ## 사용자 정보 + 닉네임: %s + 성별: %s + + ## 답변 + %s + """.formatted(nickName, gender, answers), + """ + { + "name": "<사용자 닉네임>", + "bodyTypeAnalysis": { + "type": "<체형 유형>", + "description": "<체형 설명>", + "featureBasedSuggestions": { + "emphasize": "<강조할 부분>", + "enhance": "<보완할 부분>" + } + } + } + """ + ); + } + + public String createRecommendStylePrompt(MemberInfoRequest memberInfoRequest, RecommendRequest recommendRequest) { + PromptTemplate template = new PromptTemplate(); + String recommendRequestContent = createRecommendRequestContent("패션 스타일 추천해줘", memberInfoRequest, recommendRequest); + return template.fillTemplate( + recommendRequestContent, + """ + { + "nickName": string, + "recommendedStyle": string, + "introduction": string, + "styleDirection": string, + "practicalStylingTips": string, + "imageUrl": string + } + } + """ + ); + } + + public String createRecommendItemPrompt(MemberInfoRequest memberInfoRequest, RecommendRequest recommendRequest) { + PromptTemplate template = new PromptTemplate(); + String recommendRequestContent = createRecommendRequestContent("패션 아이템 추천해줘", memberInfoRequest, recommendRequest); + return template.fillTemplate( + recommendRequestContent, + """ + { + "nickName": string, + "item": string, + "description": string, + "imageUrl": string, + } + """ + ); + } + + private String createRecommendRequestContent(String additionalText, MemberInfoRequest memberInfoRequest, RecommendRequest recommendRequest) { + return """ + ##명령 + 사용자의 체형 타입과 원하는 스타일, 선호하지 않는 스타일, 보여주고 싶은 이미지를 고려해 스타일을 추천해줘. + imageUrl은 웹에서 검색 후 존재하는 사진을 jpg 형식으로 가져와줘. + + ## 사용자 정보 + 닉네임: %s + 성별: %s + + ## 사용자 체형 타입 + \'%s\' + ## 사용자 체형 정보 + \'%s\' + + ## 사용자의 취향에 해당하는 스타일 + \'%s\' + ## 사용자가 선호하지 않는 스타일 + \'%s\' + ## 사용자가 보여주고 싶은 이미지 + \'%s\' + + %s + """ + .formatted(memberInfoRequest.getNickName(), + memberInfoRequest.getGender(), + memberInfoRequest.getBodyTypeName(), + memberInfoRequest.getBody(), + recommendRequest.getPreferredStyles(), + recommendRequest.getDislikedStyles(), + recommendRequest.getAppealedImage(), + additionalText + ); + } + +} diff --git a/src/main/java/com/example/mody/global/templates/PromptTemplate.java b/src/main/java/com/example/mody/global/templates/PromptTemplate.java new file mode 100644 index 00000000..71a15524 --- /dev/null +++ b/src/main/java/com/example/mody/global/templates/PromptTemplate.java @@ -0,0 +1,26 @@ +package com.example.mody.global.templates; + +/** + * OpenAi Api와 통신하기 위한 프롬프트 템플릿 + */ +public class PromptTemplate { + + private static final String BASE_TEMPLATE = """ + ### 요청하고 싶은 것 + {{request}} + + ### 응답 값 형식 + {{responseFormat}} + """; + + private final String template; + + public PromptTemplate() { + this.template = BASE_TEMPLATE; + } + + public String fillTemplate(String request, String responseFormat) { + return template.replace("{{request}}", request) + .replace("{{responseFormat}}", responseFormat); + } +} diff --git a/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..676d801b --- /dev/null +++ b/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.example.mody.global.util; + +import com.example.mody.global.common.base.BaseResponse; +import com.example.mody.global.common.exception.code.status.GlobalErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public CustomAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + response.setContentType("application/json; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + + // BaseResponse 형식으로 응답 생성 + BaseResponse baseResponse = BaseResponse.onFailure("COMMON401", GlobalErrorStatus._UNAUTHORIZED.getMessage(), null); + + // JSON 응답 작성 + String jsonResponse = objectMapper.writeValueAsString(baseResponse); + response.getWriter().write(jsonResponse); + } +} diff --git a/src/main/java/com/example/mody/global/util/S3FileComponent.java b/src/main/java/com/example/mody/global/util/S3FileComponent.java index b8aadd94..c6d92186 100644 --- a/src/main/java/com/example/mody/global/util/S3FileComponent.java +++ b/src/main/java/com/example/mody/global/util/S3FileComponent.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 9e0efa23..af59e07c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -6,9 +6,8 @@ spring: name: mody-server-dev datasource: driver-class-name: com.mysql.cj.jdbc.Driver - - config: - import: classpath:application.yml + config: + import: classpath:application.yml jpa: show-sql: false @@ -19,7 +18,13 @@ spring: format_sql: true jdbc: time_zone: Asia/Seoul + dialect: org.hibernate.dialect.MySQLDialect logging: level: - org.springframework.web: ERROR + root: INFO + org.springframework: INFO + config: classpath:logback-spring-mody.xml + +sentry: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5a46ab88..68372e32 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -6,9 +6,8 @@ spring: name: mody-server-local datasource: driver-class-name: com.mysql.cj.jdbc.Driver - - config: - import: classpath:application.yml + config: + import: classpath:application.yml jpa: show-sql: false @@ -20,6 +19,5 @@ spring: jdbc: time_zone: Asia/Seoul -logging: - level: - org.springframework.web: ERROR +sentry: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a3ff4857..e4a3aa6c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,9 +6,8 @@ spring: name: mody-server-prod datasource: driver-class-name: com.mysql.cj.jdbc.Driver - - config: - import: classpath:application.yml + config: + import: classpath:application.yml jpa: show-sql: false @@ -19,7 +18,13 @@ spring: format_sql: true jdbc: time_zone: Asia/Seoul + dialect: org.hibernate.dialect.MySQLDialect logging: level: - org.springframework.web: ERROR + root: INFO + org.springframework: INFO + config: classpath:logback-spring-mody.xml + +sentry: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 2bde9b07..00000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,26 +0,0 @@ -spring: - profiles: - active: local - - datasource: - url: jdbc:mysql://localhost:3306/spring-test - username: root - password: ssw030422123! - -jwt: - secret: jwt - accessExpiration: 36000000 - refreshExpiration: 864000000 - -cloud: - aws: - s3: - bucket: sample - stack: - auto: false - credentials: - accessKey: Aaaaaaaaaaaaaaaa - secretKey: 66666666666666666sssssssssss - region: - static: ap-northeast-2 - auto: false \ No newline at end of file diff --git a/src/main/resources/logback-spring-mody.xml b/src/main/resources/logback-spring-mody.xml new file mode 100644 index 00000000..94f7a9c1 --- /dev/null +++ b/src/main/resources/logback-spring-mody.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/${currentDate}/${GENERAL_LOG_FILE}.log + + + ${LOG_PATH}/%d{yyyy-MM-dd}/${GENERAL_LOG_FILE}-%i.log + 10MB + 30 + + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/${currentDate}/${ERROR_LOG_FILE}.log + + + ${LOG_PATH}/%d{yyyy-MM-dd}/${ERROR_LOG_FILE}-%i.log + 10MB + 30 + + + + ERROR + ACCEPT + DENY + + + ${LOG_PATTERN} + + + + + + + + + + diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties new file mode 100644 index 00000000..c96ed9ee --- /dev/null +++ b/src/main/resources/spy.properties @@ -0,0 +1,6 @@ +# P6Spy ?? +driverlist=com.mysql.cj.jdbc.Driver +dateformat=yyyy-MM-dd HH:mm:ss +logMessageFormat=com.p6spy.engine.spy.appender.SingleLineFormat +databaseDialectDateFormat=yyyy-MM-dd HH:mm:ss +excludecategories=info,debug,result,resultset,batch \ No newline at end of file diff --git a/src/main/resources/templates/mail/verification-email.html b/src/main/resources/templates/mail/verification-email.html new file mode 100644 index 00000000..2cc2e1f8 --- /dev/null +++ b/src/main/resources/templates/mail/verification-email.html @@ -0,0 +1,78 @@ + + + + + 이메일 인증 + + + +
+
+

이메일 인증

+
+
+

+

아래의 인증번호를 입력하여 이메일 인증을 완료해 주세요.

+ +
+ +

인증번호는 5분 동안만 유효합니다.

+ +
+

※ 본 이메일은 발신전용이며, 문의에 대한 회신은 처리되지 않습니다.

+

※ 본인이 요청하지 않은 경우 본 이메일을 무시하셔도 됩니다.

+
+
+ +
+ + \ No newline at end of file diff --git a/src/test/java/com/example/mody/domain/auth/AuthControllerTest.java b/src/test/java/com/example/mody/domain/auth/AuthControllerTest.java new file mode 100644 index 00000000..b19b9f43 --- /dev/null +++ b/src/test/java/com/example/mody/domain/auth/AuthControllerTest.java @@ -0,0 +1,2 @@ +package com.example.mody.domain.auth;public class AuthControllerTest { +} diff --git a/src/test/java/com/example/mody/domain/auth/OAuth2LoginTest.java b/src/test/java/com/example/mody/domain/auth/OAuth2LoginTest.java new file mode 100644 index 00000000..8b789762 --- /dev/null +++ b/src/test/java/com/example/mody/domain/auth/OAuth2LoginTest.java @@ -0,0 +1,2 @@ +package com.example.mody.domain.auth;public class OAuth2LoginTest { +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..e69de29b