From 0e4413020f2c634f65e2f0eaf8be804c8fbd45cb Mon Sep 17 00:00:00 2001 From: shimfff Date: Sat, 28 Dec 2024 21:08:29 +0900 Subject: [PATCH 001/281] =?UTF-8?q?:sparkles:=20feat=20:=20application.yml?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 src/main/resources/application.yml diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 2bde9b0..0000000 --- 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 From bb6c88dad4031c3a5bdd8f10890edd57fee3d225 Mon Sep 17 00:00:00 2001 From: shimfff Date: Sat, 28 Dec 2024 21:08:59 +0900 Subject: [PATCH 002/281] =?UTF-8?q?:memo:=20docs=20:=20=EA=B9=83=20?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=85=B8=EC=96=B4=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 022c0b3..f8bc0b0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ out/ ### VS Code ### .vscode/ -/application.yml \ No newline at end of file +application.yml \ No newline at end of file From ed9d5f64a37feef0fa43d2264893a1aad5c24739 Mon Sep 17 00:00:00 2001 From: shimfff Date: Sun, 29 Dec 2024 17:42:10 +0900 Subject: [PATCH 003/281] =?UTF-8?q?:memo:=20docs=20:=20=EC=9D=B4=EC=8A=88?= =?UTF-8?q?=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/issue_template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md index 32566ca..319d64a 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: '' From 9717a0b55eb0e1e61c310408e479f19cd8c2d018 Mon Sep 17 00:00:00 2001 From: shimfff Date: Sat, 4 Jan 2025 21:41:43 +0900 Subject: [PATCH 004/281] =?UTF-8?q?:wrench:=20chore=20:=20=EB=8F=84?= =?UTF-8?q?=EC=BB=A4=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6c533b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:21 +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 From d670ecdc883c036793703f2d2f04582baf0f4d9e Mon Sep 17 00:00:00 2001 From: shimfff Date: Sat, 4 Jan 2025 21:41:51 +0900 Subject: [PATCH 005/281] =?UTF-8?q?:wrench:=20chore=20:=20=EB=8F=84?= =?UTF-8?q?=EC=BB=A4=20=EC=BB=B4=ED=8F=AC=EC=A6=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose-dev.yml | 8 ++++++++ docker-compose-prod.yml | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 docker-compose-dev.yml create mode 100644 docker-compose-prod.yml diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..8b1ca39 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,8 @@ +services: # 컨테이너 설정 + backend: + container_name: mody-server-dev + image: mody-server:dev #ecr에 올린 이미지로 수정 필요 + environment: + - SPRING_PROFILES_ACTIVE=dev + ports: + - 8000:8000 diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 0000000..655ca3b --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,8 @@ +services: # 컨테이너 설정 + backend: + container_name: mody-server + image: mody-server #ecr에 올린 이미지로 수정 필요 + environment: + - SPRING_PROFILES_ACTIVE=prod + ports: + - 8080:8080 From 18207a8dd194a3b73543a230f22ee21f41f07166 Mon Sep 17 00:00:00 2001 From: shimfff Date: Sat, 4 Jan 2025 21:42:37 +0900 Subject: [PATCH 006/281] =?UTF-8?q?:wrench:=20chore=20:=20codeDeploy?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20appapec.yml=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appapec.yml | 14 ++++++++++++++ script/start.sh | 3 +++ script/stop.sh | 3 +++ 3 files changed, 20 insertions(+) create mode 100644 appapec.yml create mode 100644 script/start.sh create mode 100644 script/stop.sh diff --git a/appapec.yml b/appapec.yml new file mode 100644 index 0000000..a3ae9fd --- /dev/null +++ b/appapec.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/script/start.sh b/script/start.sh new file mode 100644 index 0000000..54ad1e8 --- /dev/null +++ b/script/start.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose -f /home/ec2-user/app/docker-compose.yml up -d diff --git a/script/stop.sh b/script/stop.sh new file mode 100644 index 0000000..cd76344 --- /dev/null +++ b/script/stop.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose -f /home/ec2-user/app/docker-compose.yml down || true From cc2d8d8fd6abf6d8d8f1e4be4520826d0d4e9bb3 Mon Sep 17 00:00:00 2001 From: shimfff Date: Sat, 4 Jan 2025 21:43:00 +0900 Subject: [PATCH 007/281] =?UTF-8?q?:wrench:=20chore=20:=20snippetsDir=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d035710..5150465 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'com.example' -version = '0.0.1-SNAPSHOT' +version = '0.0.1' java { toolchain { @@ -24,6 +24,11 @@ repositories { 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' From 9bc5e994a64bcb50cb99ff5382fe2850908f7dd9 Mon Sep 17 00:00:00 2001 From: shimfff Date: Mon, 6 Jan 2025 16:29:59 +0900 Subject: [PATCH 008/281] =?UTF-8?q?:wrench:=20chore=20:=20=EA=B9=83?= =?UTF-8?q?=ED=97=88=EB=B8=8C=20=EC=95=A1=EC=85=98=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mody-dev.yml | 74 +++++++++++++++++++++++++++++++++ .github/workflows/mody-prod.yml | 74 +++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 .github/workflows/mody-dev.yml create mode 100644 .github/workflows/mody-prod.yml diff --git a/.github/workflows/mody-dev.yml b/.github/workflows/mody-dev.yml new file mode 100644 index 0000000..de671b4 --- /dev/null +++ b/.github/workflows/mody-dev.yml @@ -0,0 +1,74 @@ +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-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. 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/.github/workflows/mody-prod.yml b/.github/workflows/mody-prod.yml new file mode 100644 index 0000000..d8cfb92 --- /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 }}: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. 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)\"}}" From e11c0742ba4f09fa05377a284cebef6738415471 Mon Sep 17 00:00:00 2001 From: Park Dongkyu <86235780+dong99u@users.noreply.github.com> Date: Tue, 7 Jan 2025 04:29:55 +0900 Subject: [PATCH 009/281] =?UTF-8?q?=E2=9C=A8=20Oauth2=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oauth2 를 이용해서 로그인 및 회원가입을 하는 API. --- .../com/example/mody/ModyApplication.java | 2 + .../auth/controller/AuthController.java | 51 ++++++++++ .../mody/domain/auth/dto/TokenDto.java | 17 ++++ .../auth/dto/response/KakaoResponse.java | 33 +++++++ .../auth/dto/response/LoginResponse.java | 18 ++++ .../auth/dto/response/OAuth2Response.java | 13 +++ .../mody/domain/auth/entity/RefreshToken.java | 49 ++++++++++ .../auth/handler/OAuth2SuccessHandler.java | 92 +++++++++++++++++++ .../auth/jwt/JwtAuthenticationFilter.java | 87 ++++++++++++++++++ .../mody/domain/auth/jwt/JwtProvider.java | 74 +++++++++++++++ .../repository/RefreshTokenRepository.java | 16 ++++ .../auth/security/CustomOAuth2User.java | 44 +++++++++ .../auth/security/OAuth2UserService.java | 56 +++++++++++ .../mody/domain/auth/service/AuthService.java | 76 +++++++++++++++ .../domain/exception/MemberException.java | 11 +++ .../member/controller/MemberController.java | 32 +++++++ .../request/MemberRegistrationRequest.java | 35 +++++++ .../mody/domain/member/entity/Member.java | 80 ++++++++++++++++ .../mody/domain/member/enums/Gender.java | 5 + .../mody/domain/member/enums/Role.java | 5 + .../mody/domain/member/enums/Status.java | 17 ++++ .../member/repository/MemberRepository.java | 15 +++ .../member/service/MemberCommandService.java | 7 ++ .../service/MemberCommandServiceImpl.java | 34 +++++++ .../mody/global/common/base/BaseResponse.java | 5 + .../code/status/MemberErrorStatus.java | 32 +++++++ .../mody/global/config/CorsMvcConfig.java | 17 ++++ .../mody/global/config/SecurityConfig.java | 83 +++++++++++++++++ src/main/resources/spy.properties | 6 ++ .../mody/domain/auth/AuthControllerTest.java | 2 + .../mody/domain/auth/OAuth2LoginTest.java | 2 + src/test/resources/application-test.yml | 0 32 files changed, 1016 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/example/mody/domain/auth/dto/TokenDto.java create mode 100644 src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java create mode 100644 src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java create mode 100644 src/main/java/com/example/mody/domain/auth/dto/response/OAuth2Response.java create mode 100644 src/main/java/com/example/mody/domain/auth/entity/RefreshToken.java create mode 100644 src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java create mode 100644 src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java create mode 100644 src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/example/mody/domain/auth/security/CustomOAuth2User.java create mode 100644 src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java create mode 100644 src/main/java/com/example/mody/domain/auth/service/AuthService.java create mode 100644 src/main/java/com/example/mody/domain/exception/MemberException.java create mode 100644 src/main/java/com/example/mody/domain/member/controller/MemberController.java create mode 100644 src/main/java/com/example/mody/domain/member/dto/request/MemberRegistrationRequest.java create mode 100644 src/main/java/com/example/mody/domain/member/entity/Member.java create mode 100644 src/main/java/com/example/mody/domain/member/enums/Gender.java create mode 100644 src/main/java/com/example/mody/domain/member/enums/Role.java create mode 100644 src/main/java/com/example/mody/domain/member/enums/Status.java create mode 100644 src/main/java/com/example/mody/domain/member/repository/MemberRepository.java create mode 100644 src/main/java/com/example/mody/domain/member/service/MemberCommandService.java create mode 100644 src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java create mode 100644 src/main/java/com/example/mody/global/common/exception/code/status/MemberErrorStatus.java create mode 100644 src/main/java/com/example/mody/global/config/CorsMvcConfig.java create mode 100644 src/main/java/com/example/mody/global/config/SecurityConfig.java create mode 100644 src/main/resources/spy.properties create mode 100644 src/test/java/com/example/mody/domain/auth/AuthControllerTest.java create mode 100644 src/test/java/com/example/mody/domain/auth/OAuth2LoginTest.java create mode 100644 src/test/resources/application-test.yml diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java index f69d93e..e0d0626 100644 --- a/src/main/java/com/example/mody/ModyApplication.java +++ b/src/main/java/com/example/mody/ModyApplication.java @@ -2,8 +2,10 @@ 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 0000000..2fc14c3 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java @@ -0,0 +1,51 @@ +package com.example.mody.domain.auth.controller; + +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +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.service.AuthService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Auth API", description = "인증 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "토큰 재발급 API", description = "Refresh Token으로 새로운 Access Token을 발급받는 API") + @PostMapping("/reissue") + public ResponseEntity reissueToken(@CookieValue(name = "refresh_token") String refreshToken, + HttpServletResponse response) { + authService.reissueToken(refreshToken, response); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "로그아웃 API", description = "로그아웃하고 Refresh Token을 제거하는 API") + @PostMapping("/logout") + public ResponseEntity logout(@CookieValue(name = "refresh_token") String refreshToken, + HttpServletResponse response) { + authService.logout(refreshToken); + + // 쿠키 삭제 + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", "") + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .maxAge(0) + .path("/") + .build(); + + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + return ResponseEntity.ok().build(); + } +} \ 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 0000000..1e1c2c9 --- /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/response/KakaoResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/KakaoResponse.java new file mode 100644 index 0000000..c82e9f0 --- /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 0000000..eb3317b --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java @@ -0,0 +1,18 @@ +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 LoginResponse { + private Long memberId; + private String nickname; + private boolean isNewMember; // 신규 회원 여부 + private boolean isRegistrationCompleted; // 회원가입 완료 여부 +} \ 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 0000000..162bbda --- /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 0000000..7ab55a8 --- /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/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..66db1f0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,92 @@ +package com.example.mody.domain.auth.handler; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.mody.domain.auth.dto.response.LoginResponse; +import com.example.mody.domain.auth.jwt.JwtProvider; +import com.example.mody.domain.auth.security.CustomOAuth2User; +import com.example.mody.domain.auth.service.AuthService; +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.example.mody.global.common.base.BaseResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final ObjectMapper objectMapper; + private final MemberRepository memberRepository; + private final AuthService authService; + + @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()); + + String accessToken = jwtProvider.createAccessToken(member.getProviderId()); + String refreshToken = jwtProvider.createRefreshToken(member.getProviderId()); + + // Refresh Token 저장 + authService.saveRefreshToken(member, refreshToken); + + // Refresh Token을 쿠키에 설정 + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", refreshToken) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .maxAge(7 * 24 * 60 * 60) // 7일 + .path("/") + .build(); + + // Access Token을 Authorization 헤더에 설정 + response.setHeader("Authorization", "Bearer " + accessToken); + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + + // 로그인 응답 데이터 설정 + LoginResponse loginResponse = LoginResponse.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .isNewMember(isNewMember) + .isRegistrationCompleted(member.isRegistrationCompleted()) + .build(); + + // 응답 바디 작성 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(BaseResponse.onSuccess(loginResponse))); + } + + 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.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 0000000..15d02ce --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,87 @@ +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.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +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; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final MemberRepository memberRepository; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + String token = resolveToken(request); + if (token != null) { + String providerId = jwtProvider.validateTokenAndGetSubject(token); + Member member = memberRepository.findByProviderId(providerId) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + member.getId(), + null, + List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole())) + ); + 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)); + } +} \ No newline at end of file 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 0000000..32e1d7c --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java @@ -0,0 +1,74 @@ +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; + +@Slf4j +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long accessTokenValidityInMilliseconds; + private final long refreshTokenValidityInMilliseconds; + + public JwtProvider( + @Value("${jwt.secret}") final String secretKey, + @Value("${jwt.accessExpiration}") final long accessTokenValidityInMilliseconds, + @Value("${jwt.refreshExpiration}") final long refreshTokenValidityInMilliseconds + ) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; + this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds; + } + + public String createAccessToken(String subject) { + return createToken(subject, accessTokenValidityInMilliseconds); + } + + 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(); + } + + 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/RefreshTokenRepository.java b/src/main/java/com/example/mody/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..aa174ad --- /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 0000000..c16beae --- /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/OAuth2UserService.java b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java new file mode 100644 index 0000000..aa4742a --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java @@ -0,0 +1,56 @@ +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.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; + + @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; + } + + // 회원가입 및 로그인 처리 + Member member = memberRepository.findByProviderId(oAuth2Response.getProviderId()) + .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.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/service/AuthService.java b/src/main/java/com/example/mody/domain/auth/service/AuthService.java new file mode 100644 index 0000000..1194f17 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/service/AuthService.java @@ -0,0 +1,76 @@ +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.entity.RefreshToken; +import com.example.mody.domain.auth.jwt.JwtProvider; +import com.example.mody.domain.auth.repository.RefreshTokenRepository; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +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; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final JwtProvider jwtProvider; + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public void reissueToken(String oldRefreshToken, HttpServletResponse response) { + // 기존 Refresh Token 검증 + RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(oldRefreshToken) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_REFRESH_TOKEN)); + + Member member = refreshTokenEntity.getMember(); + + // 새로운 토큰 발급 + String newAccessToken = jwtProvider.createAccessToken(member.getProviderId()); + String newRefreshToken = jwtProvider.createRefreshToken(member.getProviderId()); + + // Refresh Token 교체 (Rotation) + refreshTokenEntity.updateToken(newRefreshToken); + + // Refresh Token을 쿠키에 설정 + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", newRefreshToken) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .maxAge(7 * 24 * 60 * 60) // 7일 + .path("/") + .build(); + + // Access Token을 Authorization 헤더에 설정 + response.setHeader("Authorization", "Bearer " + newAccessToken); + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + } + + @Transactional + 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); + } + + @Transactional + public void logout(String refreshToken) { + RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_REFRESH_TOKEN)); + + refreshTokenRepository.delete(refreshTokenEntity); + } +} \ No newline at end of file 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 0000000..ff76811 --- /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/member/controller/MemberController.java b/src/main/java/com/example/mody/domain/member/controller/MemberController.java new file mode 100644 index 0000000..7bfd1b6 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/controller/MemberController.java @@ -0,0 +1,32 @@ +package com.example.mody.domain.member.controller; + +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.member.dto.MemberRegistrationRequest; +import com.example.mody.domain.member.service.MemberCommandService; +import com.example.mody.global.common.base.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Member API", description = "회원 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +public class MemberController { + + private final MemberCommandService memberCommandService; + + @Operation(summary = "회원가입 완료 API", description = "소셜 로그인 후 추가 정보를 입력받아 회원가입을 완료하는 API") + @PostMapping("/signup") + public BaseResponse completeRegistration( + @Valid @RequestBody MemberRegistrationRequest request) { + memberCommandService.completeRegistration(request); + return BaseResponse.onSuccess(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/member/dto/request/MemberRegistrationRequest.java b/src/main/java/com/example/mody/domain/member/dto/request/MemberRegistrationRequest.java new file mode 100644 index 0000000..6da3407 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/dto/request/MemberRegistrationRequest.java @@ -0,0 +1,35 @@ +package com.example.mody.domain.member.dto; + +import java.time.LocalDate; + +import com.example.mody.domain.member.enums.Gender; + +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; + +@Getter +@NoArgsConstructor +public class MemberRegistrationRequest { + @NotNull(message = "OAuth2 제공자의 고유 ID는 필수입니다") + private Long memberId; + + @NotBlank(message = "닉네임은 필수입니다") + private String nickname; + + @NotNull(message = "생년월일은 필수입니다") + @Past(message = "생년월일은 과거 날짜여야 합니다") + private LocalDate birthDate; + + @NotNull(message = "성별은 필수입니다") + private Gender gender; + + @NotNull(message = "키는 필수입니다") + @Positive(message = "키는 양수여야 합니다") + private Integer height; + + private String profileImageUrl; +} \ No newline at end of file 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 0000000..12c0df0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -0,0 +1,80 @@ +package com.example.mody.domain.member.entity; + +import java.time.LocalDate; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import com.example.mody.domain.member.enums.Gender; +import com.example.mody.domain.member.enums.Role; +import com.example.mody.domain.member.enums.Status; +import com.example.mody.global.common.base.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +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; + + private String providerId; // OAuth2 제공자의 고유 ID + + private String provider; // OAuth2 제공자 (kakao, google 등) + + private String email; + + private String nickname; + + private String password; + + private String profileImageUrl; + + private LocalDate birthDate; + + @Enumerated(EnumType.STRING) + private Gender gender; + + private Integer height; + + @Enumerated(EnumType.STRING) + private Status status; + + @Enumerated(EnumType.STRING) + private Role role; + + @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; + } + +} 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 0000000..f3cb6f2 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/enums/Gender.java @@ -0,0 +1,5 @@ +package com.example.mody.domain.member.enums; + +public enum Gender { + MALE, FEMALE +} 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 0000000..66e823d --- /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 { + USER, 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 0000000..8197bb5 --- /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/repository/MemberRepository.java b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..34d5eef --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java @@ -0,0 +1,15 @@ +package com.example.mody.domain.member.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.mody.domain.member.entity.Member; + +@Repository +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + + Optional findByProviderId(String providerId); +} 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 0000000..1601bfe --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.member.service; + +import com.example.mody.domain.member.dto.MemberRegistrationRequest; + +public interface MemberCommandService { + void completeRegistration(MemberRegistrationRequest request); +} 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 0000000..17c42c5 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java @@ -0,0 +1,34 @@ +package com.example.mody.domain.member.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.mody.domain.exception.MemberException; +import com.example.mody.domain.member.dto.MemberRegistrationRequest; +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 +@Transactional +public class MemberCommandServiceImpl implements MemberCommandService { + + private final MemberRepository memberRepository; + + @Override + public void completeRegistration(MemberRegistrationRequest request) { + Member member = memberRepository.findById(request.getMemberId()) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + member.completeRegistration( + request.getNickname(), + request.getBirthDate(), + request.getGender(), + request.getHeight(), + request.getProfileImageUrl() + ); + } +} \ No newline at end of file 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 db9b71c..e9d577e 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,10 @@ public static BaseResponse onSuccess(T result) { return new BaseResponse<>("COMMON200", "요청에 성공하였습니다.", 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/code/status/MemberErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/MemberErrorStatus.java new file mode 100644 index 0000000..0038907 --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/MemberErrorStatus.java @@ -0,0 +1,32 @@ +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", "해당 회원을 찾을 수 없습니다."), + ; + + 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/config/CorsMvcConfig.java b/src/main/java/com/example/mody/global/config/CorsMvcConfig.java new file mode 100644 index 0000000..0770a29 --- /dev/null +++ b/src/main/java/com/example/mody/global/config/CorsMvcConfig.java @@ -0,0 +1,17 @@ +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:3000"); + } +} \ No newline at end of file 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 0000000..b39ce3a --- /dev/null +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.example.mody.global.config; + +import java.util.Collections; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.example.mody.domain.auth.handler.OAuth2SuccessHandler; +import com.example.mody.domain.auth.jwt.JwtAuthenticationFilter; +import com.example.mody.domain.auth.jwt.JwtProvider; +import com.example.mody.domain.auth.security.OAuth2UserService; +import com.example.mody.domain.member.repository.MemberRepository; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@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; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/auth/**", "/oauth2/**").permitAll() + .requestMatchers(("/api/v1/members/signup")).permitAll() + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(oAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + http + .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { + + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + + return configuration; + } + })); + + return http.build(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtProvider, memberRepository, objectMapper); + } +} \ No newline at end of file diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties new file mode 100644 index 0000000..c96ed9e --- /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/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 0000000..b19b9f4 --- /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 0000000..8b78976 --- /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 0000000..e69de29 From ff4e75ae6429061248c1ef1e7aefaf8af1e8dba3 Mon Sep 17 00:00:00 2001 From: Park Dongkyu <86235780+dong99u@users.noreply.github.com> Date: Tue, 7 Jan 2025 23:27:28 +0900 Subject: [PATCH 010/281] =?UTF-8?q?=F0=9F=93=9D=20Swagger=20Document=20&?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A3=BC=EC=84=9D=20=EB=8B=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swagger 명세를 위한 코드 작성 및 코드 설명을 위한 주석을 달았음. --- .../auth/controller/OAuth2Controller.java | 2 ++ .../request/MemberRegistrationRequest.java | 0 .../auth/service/AuthCommandService.java | 2 ++ ...rvice.java => AuthCommandServiceImpl.java} | 11 ++++--- .../exception/RefreshTokenException.java | 2 ++ .../member/controller/MemberController.java | 32 ------------------- 6 files changed, 12 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java rename src/main/java/com/example/mody/domain/{member => auth}/dto/request/MemberRegistrationRequest.java (100%) create mode 100644 src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java rename src/main/java/com/example/mody/domain/auth/service/{AuthService.java => AuthCommandServiceImpl.java} (91%) create mode 100644 src/main/java/com/example/mody/domain/exception/RefreshTokenException.java delete mode 100644 src/main/java/com/example/mody/domain/member/controller/MemberController.java diff --git a/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java b/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java new file mode 100644 index 0000000..8920149 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java @@ -0,0 +1,2 @@ +package com.example.mody.domain.auth.controller;public class OAuth2Controller { +} diff --git a/src/main/java/com/example/mody/domain/member/dto/request/MemberRegistrationRequest.java b/src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java similarity index 100% rename from src/main/java/com/example/mody/domain/member/dto/request/MemberRegistrationRequest.java rename to src/main/java/com/example/mody/domain/auth/dto/request/MemberRegistrationRequest.java 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 0000000..4baba31 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java @@ -0,0 +1,2 @@ +package com.example.mody.domain.auth.service;public class AuthCommandService { +} diff --git a/src/main/java/com/example/mody/domain/auth/service/AuthService.java b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java similarity index 91% rename from src/main/java/com/example/mody/domain/auth/service/AuthService.java rename to src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java index 1194f17..d4ed300 100644 --- a/src/main/java/com/example/mody/domain/auth/service/AuthService.java +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java @@ -7,6 +7,7 @@ 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.domain.member.repository.MemberRepository; import com.example.mody.global.common.exception.RestApiException; @@ -14,22 +15,24 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service +@Transactional @RequiredArgsConstructor -@Transactional(readOnly = true) public class AuthService { private final JwtProvider jwtProvider; private final MemberRepository memberRepository; private final RefreshTokenRepository refreshTokenRepository; - @Transactional public void reissueToken(String oldRefreshToken, HttpServletResponse response) { // 기존 Refresh Token 검증 RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(oldRefreshToken) - .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_REFRESH_TOKEN)); + .orElseThrow(() -> new RefreshTokenException(AuthErrorStatus.INVALID_REFRESH_TOKEN)); + // Refresh Token에 해당하는 회원 조회 Member member = refreshTokenEntity.getMember(); // 새로운 토큰 발급 @@ -53,7 +56,6 @@ public void reissueToken(String oldRefreshToken, HttpServletResponse response) { response.setHeader("Set-Cookie", refreshTokenCookie.toString()); } - @Transactional public void saveRefreshToken(Member member, String refreshToken) { // 기존 리프레시 토큰이 있다면 업데이트, 없다면 새로 생성 RefreshToken refreshTokenEntity = refreshTokenRepository.findByMember(member) @@ -66,7 +68,6 @@ public void saveRefreshToken(Member member, String refreshToken) { refreshTokenRepository.save(refreshTokenEntity); } - @Transactional public void logout(String refreshToken) { RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(refreshToken) .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_REFRESH_TOKEN)); 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 0000000..a5a8d97 --- /dev/null +++ b/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java @@ -0,0 +1,2 @@ +package com.example.mody.domain.exception;public class RefreshTokenException { +} 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 deleted file mode 100644 index 7bfd1b6..0000000 --- a/src/main/java/com/example/mody/domain/member/controller/MemberController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.mody.domain.member.controller; - -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.member.dto.MemberRegistrationRequest; -import com.example.mody.domain.member.service.MemberCommandService; -import com.example.mody.global.common.base.BaseResponse; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@Tag(name = "Member API", description = "회원 관련 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/members") -public class MemberController { - - private final MemberCommandService memberCommandService; - - @Operation(summary = "회원가입 완료 API", description = "소셜 로그인 후 추가 정보를 입력받아 회원가입을 완료하는 API") - @PostMapping("/signup") - public BaseResponse completeRegistration( - @Valid @RequestBody MemberRegistrationRequest request) { - memberCommandService.completeRegistration(request); - return BaseResponse.onSuccess(null); - } -} \ No newline at end of file From 32ca45950919f7ba9c70518e70523be38156da2b Mon Sep 17 00:00:00 2001 From: Park Dongkyu <86235780+dong99u@users.noreply.github.com> Date: Tue, 7 Jan 2025 23:43:18 +0900 Subject: [PATCH 011/281] =?UTF-8?q?=E2=9C=A8=20Swagger=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swagger 명세를 위해서 컨트롤러 및 DTO에 명세를 하고 주석을 추가함. --- .../auth/controller/AuthController.java | 78 ++++++++++++++++- .../auth/controller/OAuth2Controller.java | 85 ++++++++++++++++++- .../request/MemberRegistrationRequest.java | 40 ++++++++- .../auth/dto/response/LoginResponse.java | 25 +++++- .../auth/handler/OAuth2SuccessHandler.java | 20 +++-- .../auth/jwt/JwtAuthenticationFilter.java | 15 +++- .../mody/domain/auth/jwt/JwtProvider.java | 10 +++ .../auth/security/OAuth2UserService.java | 12 ++- .../auth/service/AuthCommandService.java | 15 +++- .../auth/service/AuthCommandServiceImpl.java | 2 +- .../exception/RefreshTokenException.java | 11 ++- .../mody/domain/member/enums/Gender.java | 9 +- .../mody/domain/member/enums/Role.java | 2 +- .../member/service/MemberCommandService.java | 2 +- .../service/MemberCommandServiceImpl.java | 2 +- .../mody/global/config/SecurityConfig.java | 3 +- .../mody/global/config/SwaggerConfig.java | 77 +++++++++++------ 17 files changed, 355 insertions(+), 53 deletions(-) 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 index 2fc14c3..70d8495 100644 --- a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java @@ -4,14 +4,25 @@ import org.springframework.http.ResponseEntity; 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.service.AuthService; +import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; +import com.example.mody.domain.auth.service.AuthCommandServiceImpl; +import com.example.mody.domain.member.service.MemberCommandService; +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.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.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @Tag(name = "Auth API", description = "인증 관련 API") @@ -20,21 +31,80 @@ @RequestMapping("/auth") public class AuthController { - private final AuthService authService; + private final AuthCommandServiceImpl authCommandServiceImpl; + private final MemberCommandService memberCommandService; + /** + * 회원가입 완료 API + * @param request + * @return + */ + @Operation(summary = "회원가입 완료 API", description = "소셜 로그인 후 추가 정보를 입력받아 회원가입을 완료하는 API") + @PostMapping("/signup") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + content = @Content(schema = @Schema(implementation = BaseResponse.class)) + ) + }) + public BaseResponse completeRegistration( + @Valid @RequestBody MemberRegistrationRequest request) { + memberCommandService.completeRegistration(request); + return BaseResponse.onSuccess(null); + } + + /** + * 토큰 재발급 API + * @param refreshToken + * @param response + * @return + */ @Operation(summary = "토큰 재발급 API", description = "Refresh Token으로 새로운 Access Token을 발급받는 API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + content = @Content(schema = @Schema(implementation = BaseResponse.class)) + ) + }) + @Parameters({ + @Parameter( + name = "refresh_token", + description = "리프레시 토큰 (쿠키)", + required = true, + schema = @Schema(type = "string") + ) + }) @PostMapping("/reissue") public ResponseEntity reissueToken(@CookieValue(name = "refresh_token") String refreshToken, HttpServletResponse response) { - authService.reissueToken(refreshToken, response); + + // 토큰 재발급을 서비스 단에서 수행하도록 함. + authCommandServiceImpl.reissueToken(refreshToken, response); return ResponseEntity.ok().build(); } @Operation(summary = "로그아웃 API", description = "로그아웃하고 Refresh Token을 제거하는 API") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그아웃 성공", + content = @Content(schema = @Schema(implementation = BaseResponse.class)) + ) + }) + @Parameters({ + @Parameter( + name = "refresh_token", + description = "리프레시 토큰 (쿠키)", + required = true, + schema = @Schema(type = "string") + ) + }) @PostMapping("/logout") public ResponseEntity logout(@CookieValue(name = "refresh_token") String refreshToken, HttpServletResponse response) { - authService.logout(refreshToken); + authCommandServiceImpl.logout(refreshToken); // 쿠키 삭제 ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", "") diff --git a/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java b/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java index 8920149..a0fffe0 100644 --- a/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java +++ b/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java @@ -1,2 +1,83 @@ -package com.example.mody.domain.auth.controller;public class OAuth2Controller { -} +package com.example.mody.domain.auth.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +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; + +/** + * OAuth2 관련 API + * 스웨거 명세를 위해서 작성된 코드입니다. + */ +@Tag(name = "OAuth2", description = "소셜 로그인 관련 API") +@RestController +@RequestMapping("/oauth2") +public class OAuth2Controller { + + @Operation( + summary = "카카오 로그인 시작", + description = "카카오 로그인 페이지로 리다이렉트됩니다. " + + "프론트엔드에서는 window.location.href를 사용하여 이 URL로 이동해야 합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "302", + description = "카카오 로그인 페이지로 리다이렉트", + content = @Content( + mediaType = "text/html", + examples = @ExampleObject( + value = "카카오 로그인 페이지로 리다이렉트됩니다." + ) + ) + ) + }) + @GetMapping("/authorization/kakao") + public ResponseEntity kakaoLogin() { + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "카카오 로그인 콜백", + description = "카카오 인증 완료 후 리다이렉트되는 엔드포인트입니다. " + + "인증 코드를 받아 처리 후, 액세스 토큰과 리프레시 토큰을 발급합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = com.example.mody.domain.auth.dto.response.LoginResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패" + ) + }) + @Parameters({ + @Parameter( + name = "code", + description = "카카오에서 발급된 인증 코드", + required = true, + schema = @Schema(type = "string") + ) + }) + @GetMapping("/callback/kakao") + public ResponseEntity kakaoCallback( + @RequestParam String code + ) { + return ResponseEntity.ok().build(); + } +} \ No newline at end of file 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 index 6da3407..c4ad44d 100644 --- 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 @@ -1,9 +1,10 @@ -package com.example.mody.domain.member.dto; +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; @@ -11,25 +12,62 @@ import lombok.Getter; import lombok.NoArgsConstructor; +/** + * 회원 가입할 때 필요한 정보들 + */ +@Schema(description = "회원가입 요청 DTO") @Getter @NoArgsConstructor public class MemberRegistrationRequest { + + @Schema( + description = "회원 ID", + example = "1" + ) @NotNull(message = "OAuth2 제공자의 고유 ID는 필수입니다") private Long memberId; + @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://example.com/profile.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/LoginResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/LoginResponse.java index eb3317b..f4a3166 100644 --- 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 @@ -1,18 +1,39 @@ 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; - private boolean isNewMember; // 신규 회원 여부 - private boolean isRegistrationCompleted; // 회원가입 완료 여부 + + @Schema( + description = "신규 회원 여부", + example = "true" + ) + private boolean isNewMember; + + @Schema( + description = "회원가입 완료 여부", + example = "false" + ) + private boolean isRegistrationCompleted; } \ No newline at end of file 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 index 66db1f0..0f882c4 100644 --- a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java @@ -11,7 +11,7 @@ import com.example.mody.domain.auth.dto.response.LoginResponse; import com.example.mody.domain.auth.jwt.JwtProvider; import com.example.mody.domain.auth.security.CustomOAuth2User; -import com.example.mody.domain.auth.service.AuthService; +import com.example.mody.domain.auth.service.AuthCommandServiceImpl; import com.example.mody.domain.member.entity.Member; import com.example.mody.domain.member.enums.Role; import com.example.mody.domain.member.enums.Status; @@ -31,8 +31,16 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; private final ObjectMapper objectMapper; private final MemberRepository memberRepository; - private final AuthService authService; - + private final AuthCommandServiceImpl authCommandServiceImpl; + + /** + * OAuth2 로그인 성공 시 처리 + * @param request + * @param response + * @param authentication + * @throws IOException + * @throws ServletException + */ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { @@ -42,13 +50,15 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo Member member = memberRepository.findByProviderId(oAuth2User.getOAuth2Response().getProviderId()) .orElseGet(() -> saveMember(oAuth2User)); + // 새로 가입한 멤버인지 아닌지 확인 boolean isNewMember = member.getCreatedAt().equals(member.getUpdatedAt()); + // Access Token, Refresh Token 발급 String accessToken = jwtProvider.createAccessToken(member.getProviderId()); String refreshToken = jwtProvider.createRefreshToken(member.getProviderId()); // Refresh Token 저장 - authService.saveRefreshToken(member, refreshToken); + authCommandServiceImpl.saveRefreshToken(member, refreshToken); // Refresh Token을 쿠키에 설정 ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", refreshToken) @@ -83,7 +93,7 @@ private Member saveMember(CustomOAuth2User oAuth2User) { .provider(oAuth2User.getOAuth2Response().getProvider()) .nickname(oAuth2User.getOAuth2Response().getName()) .status(Status.ACTIVE) - .role(Role.USER) + .role(Role.ROLE_USER) .isRegistrationCompleted(false) .build(); 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 index 15d02ce..6615988 100644 --- a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -25,6 +25,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +/** + * JWT 토큰을 검증하고 인증 정보를 SecurityContextHolder에 저장하는 필터 + */ @Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -40,8 +43,13 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { try { + + // 헤더에서 토큰 추출 String token = resolveToken(request); + + // 만약 토큰이 있다면 if (token != null) { + // 토큰 검증 및 추출 String providerId = jwtProvider.validateTokenAndGetSubject(token); Member member = memberRepository.findByProviderId(providerId) .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)); @@ -49,7 +57,7 @@ protected void doFilterInternal( Authentication authentication = new UsernamePasswordAuthenticationToken( member.getId(), null, - List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole())) + List.of(new SimpleGrantedAuthority(member.getRole().toString())) ); SecurityContextHolder.getContext().setAuthentication(authentication); } @@ -63,6 +71,11 @@ protected void doFilterInternal( } } + /** + * 헤더에서 토큰을 추출하는 메서드 + * @param request + * @return + */ private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { 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 index 32e1d7c..77899a3 100644 --- a/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtProvider.java @@ -17,6 +17,9 @@ import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; +/** + * JWT 토큰 생성 및 검증을 담당하는 클래스 + */ @Slf4j @Component public class JwtProvider { @@ -26,8 +29,11 @@ public class JwtProvider { 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)); @@ -35,14 +41,17 @@ public JwtProvider( 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); @@ -55,6 +64,7 @@ private String createToken(String subject, long validityInMilliseconds) { .compact(); } + // 토큰 검증 및 subject 반환 public String validateTokenAndGetSubject(String token) { try { return Jwts.parser() 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 index aa4742a..485c7c1 100644 --- a/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java +++ b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java @@ -21,6 +21,12 @@ 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); @@ -34,7 +40,9 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic return null; } - // 회원가입 및 로그인 처리 + // 회원 정보를 조회. + // 만약 회원이 없다면 회원을 저장함. + // 그 다음 AuthController의 회원가입 API를 통해 회원가입을 완료해야 함. Member member = memberRepository.findByProviderId(oAuth2Response.getProviderId()) .orElseGet(() -> saveMember(oAuth2Response)); @@ -47,7 +55,7 @@ private Member saveMember(OAuth2Response oAuth2Response) { .provider(oAuth2Response.getProvider()) .nickname(oAuth2Response.getName()) .status(Status.ACTIVE) - .role(Role.USER) + .role(Role.ROLE_USER) .isRegistrationCompleted(false) // 최초 가입 시 미완료 상태로 설정 .build(); 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 index 4baba31..ea7418b 100644 --- a/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java @@ -1,2 +1,15 @@ -package com.example.mody.domain.auth.service;public class AuthCommandService { +package com.example.mody.domain.auth.service; + +import com.example.mody.domain.member.entity.Member; + +import jakarta.servlet.http.HttpServletResponse; + +public interface AuthCommandService { + + void reissueToken(String oldRefreshToken, HttpServletResponse response); + + void saveRefreshToken(Member member, String refreshToken); + + void logout(String refreshToken); + } 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 index d4ed300..5da9135 100644 --- a/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java @@ -21,7 +21,7 @@ @Service @Transactional @RequiredArgsConstructor -public class AuthService { +public class AuthCommandServiceImpl implements AuthCommandService { private final JwtProvider jwtProvider; private final MemberRepository memberRepository; diff --git a/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java b/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java index a5a8d97..9a17036 100644 --- a/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java +++ b/src/main/java/com/example/mody/domain/exception/RefreshTokenException.java @@ -1,2 +1,11 @@ -package com.example.mody.domain.exception;public class RefreshTokenException { +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/member/enums/Gender.java b/src/main/java/com/example/mody/domain/member/enums/Gender.java index f3cb6f2..708961b 100644 --- a/src/main/java/com/example/mody/domain/member/enums/Gender.java +++ b/src/main/java/com/example/mody/domain/member/enums/Gender.java @@ -1,5 +1,12 @@ package com.example.mody.domain.member.enums; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "성별") public enum Gender { - MALE, FEMALE + @Schema(description = "남성") + MALE, + + @Schema(description = "여성") + FEMALE } 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 index 66e823d..03e57d4 100644 --- a/src/main/java/com/example/mody/domain/member/enums/Role.java +++ b/src/main/java/com/example/mody/domain/member/enums/Role.java @@ -1,5 +1,5 @@ package com.example.mody.domain.member.enums; public enum Role { - USER, ADMIN + ROLE_USER, ROLE_ADMIN } 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 index 1601bfe..916cdfd 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java @@ -1,6 +1,6 @@ package com.example.mody.domain.member.service; -import com.example.mody.domain.member.dto.MemberRegistrationRequest; +import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; public interface MemberCommandService { void completeRegistration(MemberRegistrationRequest request); 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 index 17c42c5..22d7e0b 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java @@ -3,8 +3,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; import com.example.mody.domain.exception.MemberException; -import com.example.mody.domain.member.dto.MemberRegistrationRequest; 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; diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index b39ce3a..75950d4 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -41,7 +41,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authz -> authz .requestMatchers("/auth/**", "/oauth2/**").permitAll() - .requestMatchers(("/api/v1/members/signup")).permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 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 c50f67a..fd83ac1 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,61 @@ package com.example.mody.global.config; +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 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 From 23ff23e8a088d941ea65d4e4c3148a6802deef0c Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 14:19:36 +0900 Subject: [PATCH 012/281] =?UTF-8?q?:wrench:=20chore=20:=20start.sh=20?= =?UTF-8?q?=EA=B9=83=ED=97=88=EB=B8=8C=20=EC=8B=9C=ED=81=AC=EB=A6=BF=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mody-dev.yml | 16 ++++++++++++++-- script/start.sh | 3 --- 2 files changed, 14 insertions(+), 5 deletions(-) delete mode 100644 script/start.sh diff --git a/.github/workflows/mody-dev.yml b/.github/workflows/mody-dev.yml index de671b4..26dcaa4 100644 --- a/.github/workflows/mody-dev.yml +++ b/.github/workflows/mody-dev.yml @@ -35,6 +35,18 @@ jobs: 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: | @@ -69,6 +81,6 @@ jobs: - name: Trigger CodeDeploy Deployment run: | aws deploy create-deployment \ - --application-name my-app \ - --deployment-group-name my-app-group \ + --application-name mody-server \ + --deployment-group-name mody-server-group \ --revision "{\"revisionType\":\"AppSpecContent\",\"appSpecContent\":{\"content\":\"$(cat appspec.yml)\"}}" diff --git a/script/start.sh b/script/start.sh deleted file mode 100644 index 54ad1e8..0000000 --- a/script/start.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker-compose -f /home/ec2-user/app/docker-compose.yml up -d From 7b3f5b66b7b5baae42cd023b18f1ccf0597391aa Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 14:19:54 +0900 Subject: [PATCH 013/281] =?UTF-8?q?:wrench:=20chore=20:=20stop.sh=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/stop.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/stop.sh b/script/stop.sh index cd76344..42f1b36 100644 --- a/script/stop.sh +++ b/script/stop.sh @@ -1,3 +1,6 @@ #!/bin/bash -docker-compose -f /home/ec2-user/app/docker-compose.yml down || true +docker-compose -f /home/ec2-user/app/docker-compose-dev.yml down || true + +# 안 사용하는 이미지 정리 +docker image prune -f \ No newline at end of file From 0a206e26504732c8e7e79900f0fc8ee23e969583 Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 14:27:20 +0900 Subject: [PATCH 014/281] =?UTF-8?q?:wrench:=20chore=20:=20cicd=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/mody/ModyApplication.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java index f69d93e..14c497a 100644 --- a/src/main/java/com/example/mody/ModyApplication.java +++ b/src/main/java/com/example/mody/ModyApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +// test @SpringBootApplication public class ModyApplication { From 0c661bf7456acd01a9f26e916088e916647120f7 Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 14:38:25 +0900 Subject: [PATCH 015/281] =?UTF-8?q?:wrench:=20chore=20:=20cicd=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/mody/ModyApplication.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java index 14c497a..f69d93e 100644 --- a/src/main/java/com/example/mody/ModyApplication.java +++ b/src/main/java/com/example/mody/ModyApplication.java @@ -3,7 +3,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -// test @SpringBootApplication public class ModyApplication { From 6ec855438a826c50f1a7e6e56eacb230058dc000 Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 14:51:00 +0900 Subject: [PATCH 016/281] =?UTF-8?q?:truck:=20rename=20:=20script=20->=20sc?= =?UTF-8?q?irpts=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {script => scripts}/stop.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {script => scripts}/stop.sh (100%) diff --git a/script/stop.sh b/scripts/stop.sh similarity index 100% rename from script/stop.sh rename to scripts/stop.sh From 664337a653d33a4668f3e22b83beb5211545af5a Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 15:05:35 +0900 Subject: [PATCH 017/281] =?UTF-8?q?:wrench:=20chore=20:=20application=20na?= =?UTF-8?q?me=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mody-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mody-dev.yml b/.github/workflows/mody-dev.yml index 26dcaa4..9cb8254 100644 --- a/.github/workflows/mody-dev.yml +++ b/.github/workflows/mody-dev.yml @@ -81,6 +81,6 @@ jobs: - name: Trigger CodeDeploy Deployment run: | aws deploy create-deployment \ - --application-name mody-server \ - --deployment-group-name mody-server-group \ + --application-name modi-server \ + --deployment-group-name modi-server-group \ --revision "{\"revisionType\":\"AppSpecContent\",\"appSpecContent\":{\"content\":\"$(cat appspec.yml)\"}}" From ca7c4a7a3d43d76c918cb32741158e0563e53ca2 Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 15:43:33 +0900 Subject: [PATCH 018/281] =?UTF-8?q?:wrench:=20chore=20:=20appspec.yml?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appapec.yml => appspec.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename appapec.yml => appspec.yml (100%) diff --git a/appapec.yml b/appspec.yml similarity index 100% rename from appapec.yml rename to appspec.yml From c8ac99117201de15abfce65ba7107470ccdc83b2 Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 16:31:16 +0900 Subject: [PATCH 019/281] =?UTF-8?q?:wrench:=20chore=20:=20S3=EC=97=90=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mody-dev.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mody-dev.yml b/.github/workflows/mody-dev.yml index 9cb8254..fc55fc2 100644 --- a/.github/workflows/mody-dev.yml +++ b/.github/workflows/mody-dev.yml @@ -77,10 +77,16 @@ jobs: 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/depoly/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\":\"AppSpecContent\",\"appSpecContent\":{\"content\":\"$(cat appspec.yml)\"}}" + --revision "{\"revisionType\":\"S3\",\"s3Location\":{\"bucket\":\"modi-service-bucket\",\"key\":\"deploy/deploy.zip\",\"bundleType\":\"zip\"}}" From e20900be8e08c176bdfbb00a2e290b3b9a56fd3c Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 17:10:13 +0900 Subject: [PATCH 020/281] =?UTF-8?q?:wrench:=20chore=20:=20cicd=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/mody/ModyApplication.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java index f69d93e..736b537 100644 --- a/src/main/java/com/example/mody/ModyApplication.java +++ b/src/main/java/com/example/mody/ModyApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +// @SpringBootApplication public class ModyApplication { From a3a558d6a38551a3231d2938800a54526f6799b9 Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 17:51:19 +0900 Subject: [PATCH 021/281] =?UTF-8?q?:wrench:=20chore=20:=20=EB=B2=84?= =?UTF-8?q?=ED=82=B7=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mody-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mody-dev.yml b/.github/workflows/mody-dev.yml index fc55fc2..ccb818d 100644 --- a/.github/workflows/mody-dev.yml +++ b/.github/workflows/mody-dev.yml @@ -81,7 +81,7 @@ jobs: - name: Upload to S3 run: | zip -r deploy.zip appspec.yml scripts/ - aws s3 cp deploy.zip s3://modi-service-bucket/depoly/deploy.zip + aws s3 cp deploy.zip s3://modi-service-bucket/deploy/deploy.zip # 6. CodeDeploy 트리거 - name: Trigger CodeDeploy Deployment From 5443988987b1caa1cfc2d907a5000554ab8f9d4b Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 17:58:29 +0900 Subject: [PATCH 022/281] =?UTF-8?q?:wrench:=20chore=20:=20=ED=83=9C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/mody/ModyApplication.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java index 736b537..f69d93e 100644 --- a/src/main/java/com/example/mody/ModyApplication.java +++ b/src/main/java/com/example/mody/ModyApplication.java @@ -3,7 +3,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -// @SpringBootApplication public class ModyApplication { From 95213d0da675f86d5069cdaebd422cc10e4c72ef Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 20:59:58 +0900 Subject: [PATCH 023/281] =?UTF-8?q?:wrench:=20chore=20:=20dialect=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 9e0efa2..854131e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -6,7 +6,6 @@ spring: name: mody-server-dev datasource: driver-class-name: com.mysql.cj.jdbc.Driver - config: import: classpath:application.yml @@ -19,6 +18,7 @@ spring: format_sql: true jdbc: time_zone: Asia/Seoul + dialect: org.hibernate.dialect.MySQLDialect logging: level: From dbb6588ad3d0fcf6b2bb73a9360a7ceba8347a16 Mon Sep 17 00:00:00 2001 From: shimfff Date: Wed, 8 Jan 2025 21:10:27 +0900 Subject: [PATCH 024/281] :wrench: chore : test --- src/main/java/com/example/mody/ModyApplication.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/mody/ModyApplication.java b/src/main/java/com/example/mody/ModyApplication.java index f69d93e..736b537 100644 --- a/src/main/java/com/example/mody/ModyApplication.java +++ b/src/main/java/com/example/mody/ModyApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +// @SpringBootApplication public class ModyApplication { From b1c08e109ce1daa59b5cb12d24519c84f0649139 Mon Sep 17 00:00:00 2001 From: sshnote Date: Thu, 9 Jan 2025 20:40:08 +0900 Subject: [PATCH 025/281] =?UTF-8?q?=F0=9F=94=A7=20[#14]=20chore:=20securit?= =?UTF-8?q?y,=20oauth=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security, oauth 관련 의존성 추가입니다. --- build.gradle | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index 5150465..0ffec9f 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'com.example' -version = '0.0.1' +version = '0.0.1-SNAPSHOT' java { toolchain { @@ -25,7 +25,7 @@ repositories { } ext { - snippetsDir = file("build/generated-snippets") + snippetsDir = file('build/generated-snippets') } @@ -33,10 +33,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' - //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' + //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' @@ -64,7 +64,17 @@ dependencies { // 테스트 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' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // Oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // P6Spy + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' } tasks.named('test') { From a73c7c1f8b8967cb5d47a0ecdb59107bc7d03f1a Mon Sep 17 00:00:00 2001 From: yunseo02 <154687627+yunseo02@users.noreply.github.com> Date: Fri, 10 Jan 2025 04:00:39 +0900 Subject: [PATCH 026/281] =?UTF-8?q?=E2=9C=A8=20[#11]=20feature(Auth):=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spring security로 자체 회원가입 로그인 기능 구현하였습니다. --- .gitignore | 1 + build.gradle | 23 +++- .../domain/auth/entity/QRefreshToken.java | 64 +++++++++ .../mody/domain/member/entity/QMember.java | 72 ++++++++++ .../mody/domain/test/entity/QTest.java | 50 +++++++ .../mody/global/common/base/QBaseEntity.java | 41 ++++++ .../auth/controller/AuthController.java | 39 ++++-- .../auth/dto/request/MemberJoinRequest.java | 73 ++++++++++ .../auth/dto/request/MemberLoginReqeust.java | 27 ++++ .../auth/handler/OAuth2SuccessHandler.java | 6 +- .../auth/jwt/JwtAuthenticationFilter.java | 7 +- .../mody/domain/auth/jwt/JwtLoginFilter.java | 130 ++++++++++++++++++ .../auth/security/CustomUserDetails.java | 63 +++++++++ .../security/CustomUserDetailsService.java | 29 ++++ .../auth/service/AuthCommandService.java | 1 + .../auth/service/AuthCommandServiceImpl.java | 6 +- .../member/converter/MemberConverter.java | 23 ++++ .../mody/domain/member/entity/Member.java | 4 + .../member/repository/MemberRepository.java | 2 + .../member/service/MemberCommandService.java | 3 + .../service/MemberCommandServiceImpl.java | 21 +++ .../code/status/MemberErrorStatus.java | 1 + .../mody/global/config/SecurityConfig.java | 32 +++++ 23 files changed, 698 insertions(+), 20 deletions(-) create mode 100644 src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java create mode 100644 src/main/generated/com/example/mody/domain/member/entity/QMember.java create mode 100644 src/main/generated/com/example/mody/domain/test/entity/QTest.java create mode 100644 src/main/generated/com/example/mody/global/common/base/QBaseEntity.java create mode 100644 src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java create mode 100644 src/main/java/com/example/mody/domain/auth/dto/request/MemberLoginReqeust.java create mode 100644 src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java create mode 100644 src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java create mode 100644 src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java create mode 100644 src/main/java/com/example/mody/domain/member/converter/MemberConverter.java diff --git a/.gitignore b/.gitignore index f8bc0b0..ca52d8a 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/ diff --git a/build.gradle b/build.gradle index d035710..0ffec9f 100644 --- a/build.gradle +++ b/build.gradle @@ -24,14 +24,19 @@ repositories { 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' - //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' + //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' @@ -59,7 +64,17 @@ dependencies { // 테스트 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' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // Oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // P6Spy + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' } tasks.named('test') { 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 0000000..205d68a --- /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/member/entity/QMember.java b/src/main/generated/com/example/mody/domain/member/entity/QMember.java new file mode 100644 index 0000000..33ae481 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/member/entity/QMember.java @@ -0,0 +1,72 @@ +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; + + +/** + * 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 StringPath nickname = createString("nickname"); + + public final StringPath password = createString("password"); + + public final StringPath profileImageUrl = createString("profileImageUrl"); + + public final StringPath provider = createString("provider"); + + public final StringPath providerId = createString("providerId"); + + 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/test/entity/QTest.java b/src/main/generated/com/example/mody/domain/test/entity/QTest.java new file mode 100644 index 0000000..234f9a9 --- /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 0000000..f9f3f00 --- /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/domain/auth/controller/AuthController.java b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java index 70d8495..dc492f3 100644 --- a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java @@ -1,15 +1,13 @@ package com.example.mody.domain.auth.controller; +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; +import com.example.mody.domain.auth.dto.request.MemberLoginReqeust; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; -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 org.springframework.web.bind.annotation.*; import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; -import com.example.mody.domain.auth.service.AuthCommandServiceImpl; +import com.example.mody.domain.auth.service.AuthCommandService; import com.example.mody.domain.member.service.MemberCommandService; import com.example.mody.global.common.base.BaseResponse; @@ -31,7 +29,7 @@ @RequestMapping("/auth") public class AuthController { - private final AuthCommandServiceImpl authCommandServiceImpl; + private final AuthCommandService authCommandService; private final MemberCommandService memberCommandService; /** @@ -39,8 +37,8 @@ public class AuthController { * @param request * @return */ - @Operation(summary = "회원가입 완료 API", description = "소셜 로그인 후 추가 정보를 입력받아 회원가입을 완료하는 API") - @PostMapping("/signup") + @Operation(summary = "카카오로그인 회원가입 완료 API", description = "소셜 로그인 후 추가 정보를 입력받아 회원가입을 완료하는 API") + @PostMapping("/signup/oauth2") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -81,7 +79,7 @@ public ResponseEntity reissueToken(@CookieValue(name = "refresh_token") St HttpServletResponse response) { // 토큰 재발급을 서비스 단에서 수행하도록 함. - authCommandServiceImpl.reissueToken(refreshToken, response); + authCommandService.reissueToken(refreshToken, response); return ResponseEntity.ok().build(); } @@ -104,7 +102,7 @@ public ResponseEntity reissueToken(@CookieValue(name = "refresh_token") St @PostMapping("/logout") public ResponseEntity logout(@CookieValue(name = "refresh_token") String refreshToken, HttpServletResponse response) { - authCommandServiceImpl.logout(refreshToken); + authCommandService.logout(refreshToken); // 쿠키 삭제 ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", "") @@ -118,4 +116,23 @@ public ResponseEntity logout(@CookieValue(name = "refresh_token") String r response.setHeader("Set-Cookie", refreshTokenCookie.toString()); return ResponseEntity.ok().build(); } + + @Operation(summary = "회원가입 API") + @PostMapping("/signup") + public BaseResponse joinMember(@Valid @RequestBody MemberJoinRequest request) { + + memberCommandService.joinMember(request); + return BaseResponse.onSuccess(null); + } + + @Operation(summary = "로그인 API", description = "사용자의 이메일과 비밀번호를 통해 JWT Access Token과 Refresh Token을 발급받습니다.") + @PostMapping("/login") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "401", description = "잘못된 이메일 또는 비밀번호") + }) + public ResponseEntity authLogin(@RequestBody @Valid MemberLoginReqeust loginReqeust) { + return ResponseEntity.ok().build(); + } + } \ No newline at end of file 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 0000000..c7f0b41 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/request/MemberJoinRequest.java @@ -0,0 +1,73 @@ +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 = "비밀번호는 필수입니다") + private String password; + + @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://example.com/profile.jpg", + required = false + ) + private String profileImageUrl; +} 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 0000000..ca24c06 --- /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/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java index 0f882c4..a999d2e 100644 --- a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java @@ -11,7 +11,7 @@ import com.example.mody.domain.auth.dto.response.LoginResponse; import com.example.mody.domain.auth.jwt.JwtProvider; import com.example.mody.domain.auth.security.CustomOAuth2User; -import com.example.mody.domain.auth.service.AuthCommandServiceImpl; +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; @@ -31,7 +31,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; private final ObjectMapper objectMapper; private final MemberRepository memberRepository; - private final AuthCommandServiceImpl authCommandServiceImpl; + private final AuthCommandService authCommandService; /** * OAuth2 로그인 성공 시 처리 @@ -58,7 +58,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String refreshToken = jwtProvider.createRefreshToken(member.getProviderId()); // Refresh Token 저장 - authCommandServiceImpl.saveRefreshToken(member, refreshToken); + authCommandService.saveRefreshToken(member, refreshToken); // Refresh Token을 쿠키에 설정 ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", refreshToken) 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 index 6615988..a04c024 100644 --- a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -51,8 +51,13 @@ protected void doFilterInternal( if (token != null) { // 토큰 검증 및 추출 String providerId = jwtProvider.validateTokenAndGetSubject(token); + // findByProviderId로 Member 찾기 Member member = memberRepository.findByProviderId(providerId) - .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)); + .orElseGet(() -> + // providerId로 찾지 못했을 경우, findByEmail로 조회 + memberRepository.findByEmail(providerId) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)) + ); Authentication authentication = new UsernamePasswordAuthenticationToken( member.getId(), 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 0000000..8679f3f --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtLoginFilter.java @@ -0,0 +1,130 @@ +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.http.ResponseCookie; +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 accessToken = jwtProvider.createAccessToken(username); + String refreshToken = jwtProvider.createRefreshToken(username); + + // Refresh Token 저장 + authCommandService.saveRefreshToken(member, refreshToken); + + // Refresh Token을 쿠키에 설정 + ResponseCookie refreshTokenCookie = ResponseCookie.from("refresh_token", refreshToken) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .maxAge(7 * 24 * 60 * 60) // 7일 + .path("/") + .build(); + + // Access Token을 Authorization 헤더에 설정 + response.setHeader("Authorization", "Bearer " + accessToken); + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + + // 로그인 응답 데이터 설정 + LoginResponse loginResponse = LoginResponse.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .build(); + + // 응답 바디 작성 + 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/security/CustomUserDetails.java b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java new file mode 100644 index 0000000..f1c997c --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java @@ -0,0 +1,63 @@ +package com.example.mody.domain.auth.security; + +import com.example.mody.domain.member.entity.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.concurrent.ArrayBlockingQueue; + +@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; + } +} 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 0000000..be01428 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java @@ -0,0 +1,29 @@ +package com.example.mody.domain.auth.security; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AuthErrorStatus; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; +import lombok.RequiredArgsConstructor; +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; + +@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 RestApiException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + return new CustomUserDetails(member); + } +} 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 index ea7418b..9b4a46e 100644 --- a/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java @@ -1,5 +1,6 @@ package com.example.mody.domain.auth.service; +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; import com.example.mody.domain.member.entity.Member; import jakarta.servlet.http.HttpServletResponse; 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 index 5da9135..7c610a7 100644 --- a/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java @@ -1,6 +1,11 @@ package com.example.mody.domain.auth.service; +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; +import com.example.mody.domain.exception.MemberException; +import com.example.mody.domain.member.converter.MemberConverter; +import com.example.mody.global.common.exception.code.status.MemberErrorStatus; import org.springframework.http.ResponseCookie; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,7 +29,6 @@ public class AuthCommandServiceImpl implements AuthCommandService { private final JwtProvider jwtProvider; - private final MemberRepository memberRepository; private final RefreshTokenRepository refreshTokenRepository; public void reissueToken(String oldRefreshToken, HttpServletResponse response) { diff --git a/src/main/java/com/example/mody/domain/member/converter/MemberConverter.java b/src/main/java/com/example/mody/domain/member/converter/MemberConverter.java new file mode 100644 index 0000000..1806919 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/converter/MemberConverter.java @@ -0,0 +1,23 @@ +package com.example.mody.domain.member.converter; + +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.Role; +import com.example.mody.domain.member.enums.Status; + +public class MemberConverter { + + public static Member toMember(MemberJoinRequest request, String email) { + + return Member.builder() + .email(email) + .password(request.getPassword()) + .nickname(request.getNickname()) + .gender(request.getGender()) + .height(request.getHeight()) + .status(Status.ACTIVE) + .role(Role.ROLE_USER) + .isRegistrationCompleted(true) + .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 index 12c0df0..933a42f 100644 --- a/src/main/java/com/example/mody/domain/member/entity/Member.java +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -43,6 +43,7 @@ public class Member extends BaseEntity { private String provider; // OAuth2 제공자 (kakao, google 등) + @Column(unique = true) private String email; private String nickname; @@ -77,4 +78,7 @@ public void completeRegistration(String nickname, LocalDate birthDate, Gender ge this.isRegistrationCompleted = true; } + public void encodePassword(String password) { + this.password = password; + } } 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 index 34d5eef..ab1c3a9 100644 --- a/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java @@ -11,5 +11,7 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); + Boolean existsByEmail(String email); + Optional findByProviderId(String providerId); } 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 index 916cdfd..87c3a5f 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java @@ -1,7 +1,10 @@ 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; public interface MemberCommandService { void completeRegistration(MemberRegistrationRequest request); + + void joinMember(MemberJoinRequest request); } 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 index 22d7e0b..d776fef 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java @@ -1,5 +1,8 @@ package com.example.mody.domain.member.service; +import com.example.mody.domain.auth.dto.request.MemberJoinRequest; +import com.example.mody.domain.member.converter.MemberConverter; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +20,7 @@ public class MemberCommandServiceImpl implements MemberCommandService { private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; @Override public void completeRegistration(MemberRegistrationRequest request) { @@ -31,4 +35,21 @@ public void completeRegistration(MemberRegistrationRequest request) { request.getProfileImageUrl() ); } + + //회원가입 + @Override + public void joinMember(MemberJoinRequest request) { + String email = request.getEmail(); + Boolean isExist = memberRepository.existsByEmail(email); + + //이미 존재하는 회원인 경우 예외처리 + if (isExist) { + + throw new MemberException(MemberErrorStatus.EMAIL_ALREADY_EXISTS); + } + + Member newMember = MemberConverter.toMember(request, email); + newMember.encodePassword(passwordEncoder.encode(request.getPassword())); + memberRepository.save(newMember); + } } \ No newline at end of file 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 index 0038907..d807e25 100644 --- 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 @@ -13,6 +13,7 @@ public enum MemberErrorStatus implements BaseCodeInterface { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", "해당 회원을 찾을 수 없습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "EMAIL409", "이미 존재하는 이메일입니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index 75950d4..2b00a04 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -2,11 +2,18 @@ import java.util.Collections; +import com.example.mody.domain.auth.jwt.JwtLoginFilter; +import com.example.mody.domain.auth.service.AuthCommandService; +import com.example.mody.domain.auth.service.AuthCommandServiceImpl; 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; @@ -32,6 +39,8 @@ public class SecurityConfig { private final JwtProvider jwtProvider; private final MemberRepository memberRepository; private final ObjectMapper objectMapper; + private final AuthenticationConfiguration authenticationConfiguration; + private final AuthCommandService authCommandService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -74,6 +83,17 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { } })); + //로그인 필터 등록 + JwtLoginFilter jwtLoginFilter = new JwtLoginFilter( + authenticationManager(authenticationConfiguration), + jwtProvider, + authCommandService, + memberRepository, + objectMapper + ); + jwtLoginFilter.setFilterProcessesUrl("/auth/login"); + http.addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } @@ -81,4 +101,16 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(jwtProvider, memberRepository, objectMapper); } + + //비밀번호 암호화 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + + return configuration.getAuthenticationManager(); + } } \ No newline at end of file From 5fffd9512650d28745a22e0a75aec19188de4c9e Mon Sep 17 00:00:00 2001 From: yunseo02 <154687627+yunseo02@users.noreply.github.com> Date: Sat, 11 Jan 2025 14:52:02 +0900 Subject: [PATCH 027/281] =?UTF-8?q?=F0=9F=8E=A8=20[#11]=20rename,=20refact?= =?UTF-8?q?or:=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=95=A8=EC=88=98,=20=ED=9A=8C=EC=9B=90=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비밀 번호 설정 함수 이름 변경, 회원 오류 처리를 위해 RestApiException을 MemberExcetption으로 변경 --- .../mody/domain/auth/security/CustomUserDetailsService.java | 5 ++--- .../java/com/example/mody/domain/member/entity/Member.java | 2 +- .../mody/domain/member/service/MemberCommandServiceImpl.java | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) 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 index be01428..5cf8bcb 100644 --- a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java @@ -1,9 +1,8 @@ package com.example.mody.domain.auth.security; +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.RestApiException; -import com.example.mody.global.common.exception.code.status.AuthErrorStatus; import com.example.mody.global.common.exception.code.status.MemberErrorStatus; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; @@ -22,7 +21,7 @@ public class CustomUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Member member = memberRepository.findByEmail(username) - .orElseThrow(() -> new RestApiException(MemberErrorStatus.MEMBER_NOT_FOUND)); + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); return new CustomUserDetails(member); } 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 index 933a42f..f5cbda5 100644 --- a/src/main/java/com/example/mody/domain/member/entity/Member.java +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -78,7 +78,7 @@ public void completeRegistration(String nickname, LocalDate birthDate, Gender ge this.isRegistrationCompleted = true; } - public void encodePassword(String password) { + public void setEncodedPassword(String password) { this.password = password; } } 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 index d776fef..516b429 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java @@ -49,7 +49,7 @@ public void joinMember(MemberJoinRequest request) { } Member newMember = MemberConverter.toMember(request, email); - newMember.encodePassword(passwordEncoder.encode(request.getPassword())); + newMember.setEncodedPassword(passwordEncoder.encode(request.getPassword())); memberRepository.save(newMember); } } \ No newline at end of file From 20cae1ced9a15d858aa9772865f21168e6138a12 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 18:52:18 +0900 Subject: [PATCH 028/281] =?UTF-8?q?:sparkles:=20[#13]=20feature:=20validat?= =?UTF-8?q?ion-starter=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 0ffec9f..3219ff5 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ ext { 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-validation' //jwt 사용 0.12.3 버전 사용 implementation 'io.jsonwebtoken:jjwt-api:0.12.3' From 3d4d300a794882e381c7353ea637facaeba40de5 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 18:57:28 +0900 Subject: [PATCH 029/281] =?UTF-8?q?:recycle:=20[#13]=20refactor(auth):=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=9C=A0=EC=A0=80=EC=9D=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20CustomUserDetails=20=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=20=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EB=A5=BC=20=EC=B0=BE=EB=8A=94=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=EC=9D=84=20=EC=A7=84=ED=96=89=ED=95=98=EB=8A=94?= =?UTF-8?q?=EB=8D=B0=20=EC=9D=B4=ED=9B=84=20authorization=EC=97=90=20membe?= =?UTF-8?q?rId=EB=A5=BC=20=EB=84=A3=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20memberId=EB=A1=9C=20member=EB=A5=BC=20?= =?UTF-8?q?=EC=B0=BE=EC=95=84=EC=95=BC=ED=95=98=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=EA=B0=80=20=EB=B0=9C=EC=83=9D=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=A8.=20-=20jwt=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=ED=95=98=EB=8A=94=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=A0=EC=A0=80=EC=9D=98=20id=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20=EA=B0=96=EC=A7=80?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20=EA=B0=96=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95.=20=EB=94=B0=EB=9D=BC=EC=84=9C?= =?UTF-8?q?=20=EC=B6=94=ED=9B=84=20=ED=95=84=EC=9A=94=EC=97=86=EC=9D=84=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=98=EC=A1=B4=20=EC=82=AD=EC=A0=9C=EA=B0=80=20?= =?UTF-8?q?=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/jwt/JwtAuthenticationFilter.java | 23 ++++++++++++++++++- .../mody/global/config/SecurityConfig.java | 5 +++- 2 files changed, 26 insertions(+), 2 deletions(-) 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 index a04c024..8dfe4b0 100644 --- a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -3,7 +3,11 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Optional; +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.member.service.MemberCommandService; +import com.example.mody.domain.member.service.MemberQueryService; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -35,6 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; private final MemberRepository memberRepository; private final ObjectMapper objectMapper; + private final MemberQueryService memberQueryService; @Override protected void doFilterInternal( @@ -49,7 +54,9 @@ protected void doFilterInternal( // 만약 토큰이 있다면 if (token != null) { - // 토큰 검증 및 추출 + + /* + String providerId = jwtProvider.validateTokenAndGetSubject(token); // findByProviderId로 Member 찾기 Member member = memberRepository.findByProviderId(providerId) @@ -64,6 +71,20 @@ protected void doFilterInternal( null, List.of(new SimpleGrantedAuthority(member.getRole().toString())) ); + */ + + // 토큰 검증 및 추출 + String payload = jwtProvider.validateTokenAndGetSubject(token); + Member member = memberQueryService.findMemberById(Long.parseLong(payload)); + 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); diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index 2b00a04..f4d1d09 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -5,6 +5,8 @@ import com.example.mody.domain.auth.jwt.JwtLoginFilter; import com.example.mody.domain.auth.service.AuthCommandService; import com.example.mody.domain.auth.service.AuthCommandServiceImpl; +import com.example.mody.domain.member.service.MemberCommandService; +import com.example.mody.domain.member.service.MemberQueryService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -41,6 +43,7 @@ public class SecurityConfig { private final ObjectMapper objectMapper; private final AuthenticationConfiguration authenticationConfiguration; private final AuthCommandService authCommandService; + private final MemberQueryService memberQueryService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -99,7 +102,7 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { - return new JwtAuthenticationFilter(jwtProvider, memberRepository, objectMapper); + return new JwtAuthenticationFilter(jwtProvider, memberRepository, objectMapper, memberQueryService); } //비밀번호 암호화 From b2eb4bb9d3b9f1651dbc57b8d4ae06880a1125c5 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 18:58:05 +0900 Subject: [PATCH 030/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(auth):=20g?= =?UTF-8?q?etMember=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/mody/domain/auth/security/CustomUserDetails.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index f1c997c..b654dba 100644 --- a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java @@ -60,4 +60,8 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return true; } + + public Member getMember(){ + return this.member; + } } From cd52712d34b34f13fc7f23428ea51ece7e1f7b45 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 18:59:07 +0900 Subject: [PATCH 031/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(bodyType):?= =?UTF-8?q?=20bodytype=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mody/domain/bodytype/entity/BodyType.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java 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 0000000..69ce2ba --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java @@ -0,0 +1,23 @@ +package com.example.mody.domain.bodytype.entity; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "body_type") +public class BodyType extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "body_type_id") + private Long id; + + @Column(columnDefinition = "varchar(30)", nullable = false) + private String name; + +} From 396c33b43a59291e53a1d7dbca1034d44b32e97b Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 18:59:23 +0900 Subject: [PATCH 032/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(bodyType):?= =?UTF-8?q?=20bodytype=20=EA=B4=80=EB=A0=A8=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/exception/BodyTypeException.java | 10 +++++++ .../code/status/BodyTypeErrorStatus.java | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/exception/BodyTypeException.java create mode 100644 src/main/java/com/example/mody/global/common/exception/code/status/BodyTypeErrorStatus.java 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 0000000..9c214ed --- /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/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 0000000..fe4d20c --- /dev/null +++ b/src/main/java/com/example/mody/global/common/exception/code/status/BodyTypeErrorStatus.java @@ -0,0 +1,30 @@ +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", "체형 분석 결과를 찾을 수 없습니다."), + ; + + 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(); + } +} From 546b52ecebc96738fa34065b374c7688dd762a02 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 18:59:49 +0900 Subject: [PATCH 033/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(bodyType):?= =?UTF-8?q?=20bodytype=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bodytype/repository/BodyTypeRepository.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java 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 0000000..fff4eed --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java @@ -0,0 +1,9 @@ +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; + +@Repository +public interface BodyTypeRepository extends JpaRepository { +} From 1aa48c91608608aec28224927706aae91e659b83 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:00:41 +0900 Subject: [PATCH 034/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(bodyType):?= =?UTF-8?q?=20bodytype=20=EC=B8=A1=EC=A0=95=EC=9D=84=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EB=8A=94=20MemberBodyType=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bodytype/entity/MemberBodyType.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java b/src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java new file mode 100644 index 0000000..f1faf91 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java @@ -0,0 +1,27 @@ +package com.example.mody.domain.bodytype.entity; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Table(name = "member_body_type") +@Getter +public class MemberBodyType extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_body_type_id") + private Long id; + + @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 = 2000) + private String content; +} From dd354b48d7d3fe546ae66337966306e8cd72cf98 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:02:04 +0900 Subject: [PATCH 035/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(bodyType):?= =?UTF-8?q?=20memberBodytype=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20findLastBodyType?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9E=91=EC=84=B1=20-=20findL?= =?UTF-8?q?astBodyType=EB=8A=94=20=EB=A9=A4=EB=B2=84=EC=9D=98=20=EA=B0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B5=9C=EA=B7=BC=20=EC=B2=B4=ED=98=95=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B2=B0=EA=B3=BC=EC=9D=98=20=EC=B2=B4=ED=98=95=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MemberBodyTypeRepository.java | 13 +++++++++ .../bodytype/service/BodyTypeService.java | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java 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 0000000..54659dc --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java @@ -0,0 +1,13 @@ +package com.example.mody.domain.bodytype.repository; + +import com.example.mody.domain.bodytype.entity.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); +} 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 0000000..4c83017 --- /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.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); + } +} From f2cee91e43907cec28b272b0d5bbab0374aadd5f Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:05:32 +0900 Subject: [PATCH 036/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20p?= =?UTF-8?q?ost=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=9E=91=EC=84=B1=20=EB=B0=8F?= =?UTF-8?q?=20jpa=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/mody/domain/post/entity/Post.java | 62 +++++++++++++++++++ .../post/repository/PostRepository.java | 9 +++ 2 files changed, 71 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/post/entity/Post.java create mode 100644 src/main/java/com/example/mody/domain/post/repository/PostRepository.java 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 0000000..89bd4e3 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/entity/Post.java @@ -0,0 +1,62 @@ +package com.example.mody.domain.post.entity; + +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.DynamicUpdate; + +import java.util.ArrayList; +import java.util.List; + +import static com.example.mody.domain.post.constant.PostConstant.POST_CONTENT_LIMIT; + +@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<>(); + + @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<>(); + } + +} 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 0000000..9a430df --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/repository/PostRepository.java @@ -0,0 +1,9 @@ +package com.example.mody.domain.post.repository; + +import com.example.mody.domain.post.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostRepository extends JpaRepository { +} From 666763b203b8dbfe28fa7418dadcb0362124563d Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:06:00 +0900 Subject: [PATCH 037/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(member):?= =?UTF-8?q?=20member=20=EC=A1=B0=ED=9A=8C=20=EA=B4=80=EB=A0=A8=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=8B=A8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberQueryService.java | 7 +++++++ .../service/MemberQueryServiceImpl.java | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/member/service/MemberQueryService.java create mode 100644 src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java 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 0000000..7c0fcf4 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberQueryService.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.member.service; + +import com.example.mody.domain.member.entity.Member; + +public interface MemberQueryService { + Member findMemberById(Long memberId); +} 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 0000000..b7147a9 --- /dev/null +++ b/src/main/java/com/example/mody/domain/member/service/MemberQueryServiceImpl.java @@ -0,0 +1,20 @@ +package com.example.mody.domain.member.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; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberQueryServiceImpl implements MemberQueryService{ + private final MemberRepository memberRepository; + + @Override + public Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + } +} From 86fdc1c1d2f4dfa5afccf4905030b31f38ba1b35 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:06:43 +0900 Subject: [PATCH 038/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(member):?= =?UTF-8?q?=20member=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=20memberBodyType?= =?UTF-8?q?=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/mody/domain/member/entity/Member.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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 index f5cbda5..2768a80 100644 --- a/src/main/java/com/example/mody/domain/member/entity/Member.java +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -1,7 +1,11 @@ package com.example.mody.domain.member.entity; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import com.example.mody.domain.bodytype.entity.MemberBodyType; +import jakarta.persistence.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; @@ -10,14 +14,6 @@ import com.example.mody.domain.member.enums.Status; import com.example.mody.global.common.base.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -39,6 +35,9 @@ public class Member extends BaseEntity { @Column(name = "member_id") private Long id; + @OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL) + private List memberBodyType = new ArrayList<>(); + private String providerId; // OAuth2 제공자의 고유 ID private String provider; // OAuth2 제공자 (kakao, google 등) From e7b32b84ad6c2616aa50e8d72f490783bc800546 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:07:04 +0900 Subject: [PATCH 039/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(global):?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EA=B4=80=EB=A0=A8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/mody/global/common/base/BaseResponse.java | 4 ++++ 1 file changed, 4 insertions(+) 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 e9d577e..ce0528c 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 @@ -25,6 +25,10 @@ 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 of(BaseCodeInterface code, T result) { return new BaseResponse<>(code.getCode().getCode(), code.getCode().getMessage(), result); } From 87cdd35f651e0242bbe1b6263ee70a4d87cc3957 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:07:49 +0900 Subject: [PATCH 040/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20p?= =?UTF-8?q?ost=20=EA=B4=80=EB=A0=A8=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/mody/domain/exception/PostException.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/exception/PostException.java 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 0000000..61b4c99 --- /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); + } +} From caae017e0de1a4419c1e2c350b8b1e53b7f197aa Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:09:02 +0900 Subject: [PATCH 041/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20p?= =?UTF-8?q?ostImage=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80.=20?= =?UTF-8?q?post=EC=99=80=20=EB=8B=A4=EB=8C=80=EC=9D=BC=20=EC=96=91?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EA=B4=80=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mody/domain/post/entity/PostImage.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/post/entity/PostImage.java 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 0000000..da9f64e --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/entity/PostImage.java @@ -0,0 +1,28 @@ +package com.example.mody.domain.post.entity; + +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_image") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +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 url){ + this.post = post; + this.url = url; + } +} From f145c88e78fb8c7111ccd81449771b05326630cd Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:09:18 +0900 Subject: [PATCH 042/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20p?= =?UTF-8?q?ost=20=EB=93=B1=EB=A1=9D=EC=9D=84=20=EC=9C=84=ED=95=9C=20dto=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/dto/request/PostCreateRequest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java 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 0000000..d4ca9a9 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/dto/request/PostCreateRequest.java @@ -0,0 +1,53 @@ +package com.example.mody.domain.post.dto.request; + +import com.example.mody.domain.backupimage.dto.request.BackupFileRequest; +import com.example.mody.domain.bodytype.entity.BodyType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +import java.util.List; + +import static com.example.mody.domain.post.constant.PostConstant.POST_CONTENT_LIMIT; + +@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 = "[{" + + "\"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\"" + + "}]" + ) + private List files; +} From f9101a8f83f95b6e335155c0384dd816e08b2d60 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:09:38 +0900 Subject: [PATCH 043/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=93=B1=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20api=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/post/controller/PostController.java 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 0000000..2fff335 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/controller/PostController.java @@ -0,0 +1,46 @@ +package com.example.mody.domain.post.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.post.dto.request.PostCreateRequest; +import com.example.mody.domain.post.service.PostCommandService; +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 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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "POST API", description = "게시글 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts") +public class PostController { + + private final PostCommandService postCommandService; + + /** + * + * @param request + * @param customUserDetails 현재는 동작하지 않으므로 SecurityContextHolder에서 직접 memberId를 추출하여 사용함. memberId로 member를 찾는 부분도 서비스 단으로 들어가는게 좋다고 판단되지만, 인증 부분이 + * @return + */ + @PostMapping + @Operation(summary = "게시글 작성 API", description = "인증된 유저의 게시글 작성 API") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "게시글 작성 성공" + ) + }) + public BaseResponse registerPost( + @Valid @RequestBody PostCreateRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + postCommandService.createPost(request, customUserDetails.getMember()); + return BaseResponse.onSuccessCreate(null); + } +} From f8fd8b686bf9f75027366d715f9acf52c794264b Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:11:12 +0900 Subject: [PATCH 044/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=93=B1=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=8B=A8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20-=20=EC=9A=94=EC=B2=AD=20dto=EB=A1=9C?= =?UTF-8?q?=EB=B6=80=ED=84=B0=20postImage,=20post,=20backupFile=203?= =?UTF-8?q?=EA=B0=80=EC=A7=80=EB=A5=BC=20=EC=83=9D=EC=84=B1=ED=95=A8.=20-?= =?UTF-8?q?=20postImage,=20post=EB=8A=94=20=EC=96=91=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=EA=B0=80=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=20-=20backupFile=EC=9D=80=20=EC=97=B0=EA=B4=80?= =?UTF-8?q?=EA=B4=80=EA=B3=84=EA=B0=80=20=EC=A1=B4=EC=9E=AC=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/service/PostCommandService.java | 9 ++++ .../post/service/PostCommandServiceImpl.java | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/post/service/PostCommandService.java create mode 100644 src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java 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 0000000..2a687a7 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/service/PostCommandService.java @@ -0,0 +1,9 @@ +package com.example.mody.domain.post.service; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.post.dto.request.PostCreateRequest; + +public interface PostCommandService { + + public void createPost(PostCreateRequest postCreateRequest, 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 0000000..f7b6c40 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java @@ -0,0 +1,53 @@ +package com.example.mody.domain.post.service; + +import com.example.mody.domain.backupimage.service.BackupFileService; +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.member.entity.Member; +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.repository.PostRepository; +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.BodyTypeErrorStatus.MEMBER_BODY_TYPE_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class PostCommandServiceImpl implements PostCommandService{ + private final PostRepository postRepository; + + private final BodyTypeService bodyTypeService; + private final BackupFileService backupFileService; + + /** + * 게시글 작성 비즈니스 로직. BodyType은 요청 유저의 가장 마지막 BodyType을 적용함. 유저의 BodyType이 존재하지 않을 경우 예외 발생. + * @param postCreateRequest + * @param member + */ + @Override + @Transactional + 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.getFiles().forEach(file->{ + PostImage postImage = new PostImage(post, file.getS3Url()); + post.getImages().add(postImage); + backupFileService.saveBackupFile(file); // 백업파일 저장 + }); + + postRepository.save(post); + } +} From 19fff00f5c99ec86ee967a5f826d9f342ca10a0d Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:11:40 +0900 Subject: [PATCH 045/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20?= =?UTF-8?q?=EC=83=81=EC=88=98=EB=A5=BC=20=ED=86=B5=ED=95=B4=20post=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B0=92=EB=93=A4=EC=9D=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/mody/domain/post/constant/PostConstant.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/post/constant/PostConstant.java 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 0000000..7bf92d4 --- /dev/null +++ b/src/main/java/com/example/mody/domain/post/constant/PostConstant.java @@ -0,0 +1,5 @@ +package com.example.mody.domain.post.constant; + +public class PostConstant { + public static final int POST_CONTENT_LIMIT = 1000; +} From 38659263cd8dc0b527b0d805d6fd68a237b8d8d5 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:11:49 +0900 Subject: [PATCH 046/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B0=B1=EC=97=85=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/backupimage/entity/BackupFile.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/backupimage/entity/BackupFile.java diff --git a/src/main/java/com/example/mody/domain/backupimage/entity/BackupFile.java b/src/main/java/com/example/mody/domain/backupimage/entity/BackupFile.java new file mode 100644 index 0000000..0fff009 --- /dev/null +++ b/src/main/java/com/example/mody/domain/backupimage/entity/BackupFile.java @@ -0,0 +1,29 @@ +package com.example.mody.domain.backupimage.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; + } + +} From 665e4940dc8639f3e7eecae5dfb07e4f83d8f5bf Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:12:10 +0900 Subject: [PATCH 047/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B0=B1=EC=97=85=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20dto=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/BackupFileRequest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/backupimage/dto/request/BackupFileRequest.java diff --git a/src/main/java/com/example/mody/domain/backupimage/dto/request/BackupFileRequest.java b/src/main/java/com/example/mody/domain/backupimage/dto/request/BackupFileRequest.java new file mode 100644 index 0000000..43ae495 --- /dev/null +++ b/src/main/java/com/example/mody/domain/backupimage/dto/request/BackupFileRequest.java @@ -0,0 +1,35 @@ +package com.example.mody.domain.backupimage.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; +} From 15fb0db20c4dcfb67846bc042a3de2ce5ffc93a6 Mon Sep 17 00:00:00 2001 From: jher235 Date: Sat, 11 Jan 2025 19:12:47 +0900 Subject: [PATCH 048/281] =?UTF-8?q?:sparkles:=20[#13]=20feature(post):=20?= =?UTF-8?q?=EB=B0=B1=EC=97=85=EC=9A=A9=20=ED=8C=8C=EC=9D=BC=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20=EB=93=B1=EB=A1=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20-=20jpa=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BackupFileRepository.java | 9 ++++++++ .../service/BackupFileService.java | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/backupimage/repository/BackupFileRepository.java create mode 100644 src/main/java/com/example/mody/domain/backupimage/service/BackupFileService.java diff --git a/src/main/java/com/example/mody/domain/backupimage/repository/BackupFileRepository.java b/src/main/java/com/example/mody/domain/backupimage/repository/BackupFileRepository.java new file mode 100644 index 0000000..01e0d5c --- /dev/null +++ b/src/main/java/com/example/mody/domain/backupimage/repository/BackupFileRepository.java @@ -0,0 +1,9 @@ +package com.example.mody.domain.backupimage.repository; + +import com.example.mody.domain.backupimage.entity.BackupFile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BackupFileRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/backupimage/service/BackupFileService.java b/src/main/java/com/example/mody/domain/backupimage/service/BackupFileService.java new file mode 100644 index 0000000..5b17a6c --- /dev/null +++ b/src/main/java/com/example/mody/domain/backupimage/service/BackupFileService.java @@ -0,0 +1,21 @@ +package com.example.mody.domain.backupimage.service; + +import com.example.mody.domain.backupimage.dto.request.BackupFileRequest; +import com.example.mody.domain.backupimage.entity.BackupFile; +import com.example.mody.domain.backupimage.repository.BackupFileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BackupFileService { + private final BackupFileRepository backupFileRepository; + + public void saveBackupFile(BackupFileRequest backupFileRequest){ + BackupFile backupFile = new BackupFile(backupFileRequest.getFileName(), + backupFileRequest.getFileSize(), + backupFileRequest.getS3Url()); + + backupFileRepository.save(backupFile); + } +} From d442d53fcc064073a075931130b40c923e46fd11 Mon Sep 17 00:00:00 2001 From: sshnote Date: Sun, 12 Jan 2025 21:08:37 +0900 Subject: [PATCH 049/281] =?UTF-8?q?=E2=9C=A8=20[#16]=20feature:=20?= =?UTF-8?q?=EC=B2=B4=ED=98=95=20=EB=B6=84=EC=84=9D=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAi API를 통해 체형 분석을 하는 API입니다. --- build.gradle | 23 ++++-- .../controller/BodyTypeController.java | 39 +++++++++ .../dto/BodyTypeAnalysisResponse.java | 55 +++++++++++++ .../mody/domain/bodytype/entity/Answer.java | 32 ++++++++ .../mody/domain/bodytype/entity/BodyType.java | 32 ++++++++ .../mody/domain/bodytype/entity/Question.java | 31 +++++++ .../bodytype/entity/mapping/MemberAnswer.java | 32 ++++++++ .../entity/mapping/MemberBodyType.java | 35 ++++++++ .../chatgpt/service/ChatGptService.java | 81 +++++++++++++++++++ .../mody/domain/member/entity/Member.java | 15 ++-- .../code/status/AnalysisErrorStatus.java | 37 +++++++++ .../mody/global/config/CorsMvcConfig.java | 4 +- .../mody/global/config/OpenAiConfig.java | 31 +++++++ .../mody/global/config/SecurityConfig.java | 1 + .../infrastructure/ai/OpenAiApiClient.java | 34 ++++++++ .../infrastructure/ai/dto/ChatGPTRequest.java | 17 ++++ .../ai/dto/ChatGPTResponse.java | 25 ++++++ .../global/infrastructure/ai/dto/Message.java | 16 ++++ .../mody/global/templates/PromptManager.java | 42 ++++++++++ .../mody/global/templates/PromptTemplate.java | 26 ++++++ 20 files changed, 592 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/dto/BodyTypeAnalysisResponse.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/Answer.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/Question.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java create mode 100644 src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java create mode 100644 src/main/java/com/example/mody/global/common/exception/code/status/AnalysisErrorStatus.java create mode 100644 src/main/java/com/example/mody/global/config/OpenAiConfig.java create mode 100644 src/main/java/com/example/mody/global/infrastructure/ai/OpenAiApiClient.java create mode 100644 src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTRequest.java create mode 100644 src/main/java/com/example/mody/global/infrastructure/ai/dto/ChatGPTResponse.java create mode 100644 src/main/java/com/example/mody/global/infrastructure/ai/dto/Message.java create mode 100644 src/main/java/com/example/mody/global/templates/PromptManager.java create mode 100644 src/main/java/com/example/mody/global/templates/PromptTemplate.java diff --git a/build.gradle b/build.gradle index 5150465..3219ff5 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'com.example' -version = '0.0.1' +version = '0.0.1-SNAPSHOT' java { toolchain { @@ -25,18 +25,19 @@ repositories { } ext { - snippetsDir = file("build/generated-snippets") + 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-validation' - //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' + //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' @@ -64,7 +65,17 @@ dependencies { // 테스트 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' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // Oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // P6Spy + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' } tasks.named('test') { 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 0000000..c4d8d56 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java @@ -0,0 +1,39 @@ +package com.example.mody.domain.bodytype.controller; + +import com.example.mody.domain.bodytype.dto.BodyTypeAnalysisResponse; +import com.example.mody.domain.chatgpt.service.ChatGptService; +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.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "체형 분석", description = "체형 분석 API") +@RestController +@RequestMapping("/body-analysis") +@RequiredArgsConstructor +public class BodyTypeController { + + private final ChatGptService chatGptService; + + @PostMapping("/result") + @Operation(summary = "체형 분석 API", description = "OpenAi를 사용해서 체형을 분석하는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공") + }) + @Parameters({ + @Parameter(name = "name", description = "사용자 이름", required = true), + @Parameter(name = "gender", description = "사용자 성별", required = true) + }) + public BaseResponse analyzeBodyType( + @RequestParam @NotBlank(message = "query string으로, 이름을 필수로 입력해 주세요!") String name, + @RequestParam @NotBlank(message = "query string으로, 성별을 필수로 입력해 주세요!") String gender, + @RequestBody @NotBlank(message = "request body로, 질문에 맞는 답변 목록을 String으로 보내주세요!") String answers + ) { + return BaseResponse.onSuccess(chatGptService.analyzeBodyType(name, gender, answers)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/bodytype/dto/BodyTypeAnalysisResponse.java b/src/main/java/com/example/mody/domain/bodytype/dto/BodyTypeAnalysisResponse.java new file mode 100644 index 0000000..0aca538 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/dto/BodyTypeAnalysisResponse.java @@ -0,0 +1,55 @@ +package com.example.mody.domain.bodytype.dto; + +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 0000000..40442ab --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/Answer.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; + +@Entity +@Getter +@Setter +@DynamicUpdate +@DynamicInsert +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Answer extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + 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 0000000..9094d35 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java @@ -0,0 +1,32 @@ +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) + private Long id; + + @Column(nullable = false, length = 30) + private String name; + + @OneToMany(mappedBy = "bodyType", cascade = CascadeType.ALL) + private List memberBodyTypeList = new ArrayList<>(); +} 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 0000000..1562cf6 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/Question.java @@ -0,0 +1,31 @@ +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) + 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 0000000..84357b2 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberAnswer.java @@ -0,0 +1,32 @@ +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) + 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 0000000..e8c660e --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/entity/mapping/MemberBodyType.java @@ -0,0 +1,35 @@ +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) + private Long id; + + @Column(nullable = false, length = 500) + 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; +} 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 0000000..9b33008 --- /dev/null +++ b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java @@ -0,0 +1,81 @@ +package com.example.mody.domain.chatgpt.service; + +import com.example.mody.domain.bodytype.dto.BodyTypeAnalysisResponse; +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 응답 변환 + + @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"; // 대화에서의 역할(사용자 메시지) + + // 체형 분석 메서드 + public BodyTypeAnalysisResponse analyzeBodyType(String name, String gender, String answers) { + + // 템플릿 생성 + String prompt = promptManager.createBodyTypeAnalysisPrompt(name, gender, answers); + + // OpenAI API 호출 + ChatGPTResponse response = openAiApiClient.sendRequestToModel( + model, + List.of( + new Message(systemRole, prompt) + ), + maxTokens, + temperature + ); + + // 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); + + 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); + } + } +} \ No newline at end of file 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 index 12c0df0..7b4e8e9 100644 --- a/src/main/java/com/example/mody/domain/member/entity/Member.java +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -1,7 +1,11 @@ package com.example.mody.domain.member.entity; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import jakarta.persistence.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; @@ -10,14 +14,6 @@ import com.example.mody.domain.member.enums.Status; import com.example.mody.global.common.base.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -67,6 +63,9 @@ public class Member extends BaseEntity { @Builder.Default private boolean isRegistrationCompleted = false; // 회원가입 완료 여부 + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + private List memberBodyTypeList = new ArrayList<>(); + public void completeRegistration(String nickname, LocalDate birthDate, Gender gender, Integer height , String profileImageUrl) { this.nickname = nickname; 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 0000000..f5086cc --- /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/config/CorsMvcConfig.java b/src/main/java/com/example/mody/global/config/CorsMvcConfig.java index 0770a29..f30cd05 100644 --- a/src/main/java/com/example/mody/global/config/CorsMvcConfig.java +++ b/src/main/java/com/example/mody/global/config/CorsMvcConfig.java @@ -11,7 +11,7 @@ public class CorsMvcConfig implements WebMvcConfigurer { public void addCorsMappings(CorsRegistry corsRegistry) { corsRegistry.addMapping("/**") - .exposedHeaders("Set-Cookie") - .allowedOrigins("http://localhost:3000"); + .exposedHeaders("Set-Cookie") + .allowedOrigins("http://localhost:3000"); } } \ 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 0000000..15a2f89 --- /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/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index 75950d4..df32131 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -42,6 +42,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authz -> authz .requestMatchers("/auth/**", "/oauth2/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .requestMatchers("/body-analysis/result").permitAll() // 체형 분석 테스트용 .anyRequest().authenticated() ) 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 0000000..7fd25b4 --- /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 0000000..2595a41 --- /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 0000000..c5e1f0b --- /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 0000000..d7f9450 --- /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 0000000..ea288ca --- /dev/null +++ b/src/main/java/com/example/mody/global/templates/PromptManager.java @@ -0,0 +1,42 @@ +package com.example.mody.global.templates; + +import org.springframework.stereotype.Component; + +/** + * PromptTemplate에 맞게 프롬프트를 생성, 관리하는 클래스 + */ +@Component +public class PromptManager { + + // 체형 분석 프롬프트 생성 메서드 + public String createBodyTypeAnalysisPrompt(String name, String gender, String answers) { + PromptTemplate template = new PromptTemplate(); + return template.fillTemplate( + """ + ## 명령 + 이름과 성별, 그리고 사용자의 답변을 기반으로 체형 타입(네추럴, 스트레이트, 웨이브 중 하나)을 분석하고, 설명과 스타일링 팁을 제공해줘. + 결과는 JSON 형식으로 반환해줘. + + ## 사용자 정보 + 이름: %s + 성별: %s + + ## 답변 + %s + """.formatted(name, gender, answers), + """ + { + "name": "<사용자 이름>", + "bodyTypeAnalysis": { + "type": "<체형 유형>", + "description": "<체형 설명>", + "featureBasedSuggestions": { + "emphasize": "<강조할 부분>", + "enhance": "<보완할 부분>" + } + } + } + """ + ); + } +} 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 0000000..71a1552 --- /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); + } +} From 6c5df8ae48ee3ce6f6072fd140833a54437671f3 Mon Sep 17 00:00:00 2001 From: sshnote Date: Mon, 13 Jan 2025 15:21:19 +0900 Subject: [PATCH 050/281] =?UTF-8?q?=F0=9F=94=A8=20[#23]=20refactor:=20Body?= =?UTF-8?q?Type=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 체형 분석 관련 엔티티가 충돌이 나는 오류를 수정했습니다. --- .../bodytype/entity/MemberBodyType.java | 27 ------------------- .../entity/mapping/MemberBodyType.java | 2 +- .../repository/MemberBodyTypeRepository.java | 2 +- .../bodytype/service/BodyTypeService.java | 2 +- 4 files changed, 3 insertions(+), 30 deletions(-) delete mode 100644 src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java b/src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java deleted file mode 100644 index f1faf91..0000000 --- a/src/main/java/com/example/mody/domain/bodytype/entity/MemberBodyType.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.mody.domain.bodytype.entity; - -import com.example.mody.domain.member.entity.Member; -import com.example.mody.global.common.base.BaseEntity; -import jakarta.persistence.*; -import lombok.Getter; - -@Entity -@Table(name = "member_body_type") -@Getter -public class MemberBodyType extends BaseEntity { - - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "member_body_type_id") - private Long id; - - @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 = 2000) - private String content; -} 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 index e8c660e..fc5bd69 100644 --- 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 @@ -22,7 +22,7 @@ public class MemberBodyType extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, length = 500) + @Column(nullable = false, length = 1000) private String body; @ManyToOne(fetch = FetchType.LAZY) 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 index 54659dc..0eff484 100644 --- a/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java +++ b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java @@ -1,6 +1,6 @@ package com.example.mody.domain.bodytype.repository; -import com.example.mody.domain.bodytype.entity.MemberBodyType; +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; 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 index 4c83017..3ded547 100644 --- a/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java +++ b/src/main/java/com/example/mody/domain/bodytype/service/BodyTypeService.java @@ -1,7 +1,7 @@ package com.example.mody.domain.bodytype.service; import com.example.mody.domain.bodytype.entity.BodyType; -import com.example.mody.domain.bodytype.entity.MemberBodyType; +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; From 50424a5063081a8273dd949bf949e282091bac82 Mon Sep 17 00:00:00 2001 From: shimfff Date: Mon, 13 Jan 2025 21:03:22 +0900 Subject: [PATCH 051/281] =?UTF-8?q?:memo:=20docs:=20=EA=B9=83=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=85=B8=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ca52d8a..dbc9e47 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ out/ ### VS Code ### .vscode/ -application.yml \ No newline at end of file +application.yml + +generated/ From 0cc66d8bee871d49ab7a01744de2ec673c250279 Mon Sep 17 00:00:00 2001 From: shimfff Date: Mon, 13 Jan 2025 21:04:34 +0900 Subject: [PATCH 052/281] =?UTF-8?q?:memo:=20docs:=20=EA=B9=83=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=85=B8=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dbc9e47..bf8ad7a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ out/ application.yml -generated/ +!**/src/main/generated/ From 60598d5a8725bc090466350167f9470e8a90b408 Mon Sep 17 00:00:00 2001 From: shimfff Date: Mon, 13 Jan 2025 21:05:05 +0900 Subject: [PATCH 053/281] =?UTF-8?q?Qclass=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/entity/QRefreshToken.java | 64 ----------------- .../mody/domain/member/entity/QMember.java | 72 ------------------- .../mody/domain/test/entity/QTest.java | 50 ------------- .../mody/global/common/base/QBaseEntity.java | 41 ----------- 4 files changed, 227 deletions(-) delete mode 100644 src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java delete mode 100644 src/main/generated/com/example/mody/domain/member/entity/QMember.java delete mode 100644 src/main/generated/com/example/mody/domain/test/entity/QTest.java delete mode 100644 src/main/generated/com/example/mody/global/common/base/QBaseEntity.java 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 deleted file mode 100644 index 205d68a..0000000 --- a/src/main/generated/com/example/mody/domain/auth/entity/QRefreshToken.java +++ /dev/null @@ -1,64 +0,0 @@ -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/member/entity/QMember.java b/src/main/generated/com/example/mody/domain/member/entity/QMember.java deleted file mode 100644 index 33ae481..0000000 --- a/src/main/generated/com/example/mody/domain/member/entity/QMember.java +++ /dev/null @@ -1,72 +0,0 @@ -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; - - -/** - * 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 StringPath nickname = createString("nickname"); - - public final StringPath password = createString("password"); - - public final StringPath profileImageUrl = createString("profileImageUrl"); - - public final StringPath provider = createString("provider"); - - public final StringPath providerId = createString("providerId"); - - 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/test/entity/QTest.java b/src/main/generated/com/example/mody/domain/test/entity/QTest.java deleted file mode 100644 index 234f9a9..0000000 --- a/src/main/generated/com/example/mody/domain/test/entity/QTest.java +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index f9f3f00..0000000 --- a/src/main/generated/com/example/mody/global/common/base/QBaseEntity.java +++ /dev/null @@ -1,41 +0,0 @@ -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); - } - -} - From c81c4062cc978aaba6116e4ea779ba0c18b765e0 Mon Sep 17 00:00:00 2001 From: shimfff Date: Mon, 13 Jan 2025 22:34:11 +0900 Subject: [PATCH 054/281] =?UTF-8?q?:sparkles:=20feat:=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/common/exception/ExceptionAdvice.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 7839c87..3649d1f 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 @@ -8,6 +8,8 @@ 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; @@ -82,6 +84,19 @@ public ResponseEntity handleMethodArgumentNotValid( } + // 인증되지 않은 사용자에 대한 처리 + @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()) From 3d5176c316f0fb9ff385439858601ef284c5fc07 Mon Sep 17 00:00:00 2001 From: shimfff Date: Mon, 13 Jan 2025 22:36:12 +0900 Subject: [PATCH 055/281] =?UTF-8?q?:sparkles:=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EC=A0=9C=EC=99=B8=ED=95=9C=20api=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=95=88?= =?UTF-8?q?=EB=90=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../code/status/GlobalErrorStatus.java | 1 + .../mody/global/config/SecurityConfig.java | 170 +++++++++--------- .../util/CustomAuthenticationEntryPoint.java | 20 +++ 3 files changed, 108 insertions(+), 83 deletions(-) create mode 100644 src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java 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 ce05663..6362c3a 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", "파일 업로드에 실패했습니다."), diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index 3c60828..c40c822 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -4,15 +4,15 @@ import com.example.mody.domain.auth.jwt.JwtLoginFilter; import com.example.mody.domain.auth.service.AuthCommandService; -import com.example.mody.domain.auth.service.AuthCommandServiceImpl; -import com.example.mody.domain.member.service.MemberCommandService; import com.example.mody.domain.member.service.MemberQueryService; +import com.example.mody.global.util.CustomAuthenticationEntryPoint; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -36,85 +36,89 @@ @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; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> csrf.disable()) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(authz -> authz - .requestMatchers("/auth/**", "/oauth2/**").permitAll() - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() - .requestMatchers("/body-analysis/result").permitAll() // 체형 분석 테스트용 - - .anyRequest().authenticated() - ) - .oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfo -> userInfo - .userService(oAuth2UserService) - ) - .successHandler(oAuth2SuccessHandler) - ) - .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); - - http - .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { - - @Override - public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { - - CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); - configuration.setAllowedMethods(Collections.singletonList("*")); - configuration.setAllowCredentials(true); - configuration.setAllowedHeaders(Collections.singletonList("*")); - configuration.setMaxAge(3600L); - - configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); - configuration.setExposedHeaders(Collections.singletonList("Authorization")); - - return configuration; - } - })); - - //로그인 필터 등록 - JwtLoginFilter jwtLoginFilter = new JwtLoginFilter( - authenticationManager(authenticationConfiguration), - jwtProvider, - authCommandService, - memberRepository, - objectMapper - ); - jwtLoginFilter.setFilterProcessesUrl("/auth/login"); - http.addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } - - @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(); - } + 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; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/auth/**", "/oauth2/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .requestMatchers("/body-analysis/result").permitAll() // 체형 분석 테스트용 + + .anyRequest().authenticated() + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 커스텀 EntryPoint 등록 + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(oAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + http + .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { + + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + + return configuration; + } + })); + + //로그인 필터 등록 + JwtLoginFilter jwtLoginFilter = new JwtLoginFilter( + authenticationManager(authenticationConfiguration), + jwtProvider, + authCommandService, + memberRepository, + objectMapper + ); + jwtLoginFilter.setFilterProcessesUrl("/auth/login"); + http.addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @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(); + } } \ No newline at end of file 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 0000000..240a8ae --- /dev/null +++ b/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java @@ -0,0 +1,20 @@ +package com.example.mody.global.util; + +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 { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"로그인이 필요합니다.\"}"); + } +} From a4451994263a6b257acaaca83bfcc664d1350f58 Mon Sep 17 00:00:00 2001 From: shimfff Date: Mon, 13 Jan 2025 22:52:21 +0900 Subject: [PATCH 056/281] =?UTF-8?q?:sparkles:=20feat:=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=ED=98=95=EC=8B=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mody/global/config/SecurityConfig.java | 2 +- .../util/CustomAuthenticationEntryPoint.java | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index c40c822..e596051 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -60,7 +60,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .anyRequest().authenticated() ) .exceptionHandling(exception -> exception - .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 커스텀 EntryPoint 등록 + .authenticationEntryPoint(new CustomAuthenticationEntryPoint(objectMapper)) // 커스텀 EntryPoint 등록 ) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo diff --git a/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java index 240a8ae..676d801 100644 --- a/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/example/mody/global/util/CustomAuthenticationEntryPoint.java @@ -1,5 +1,8 @@ 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; @@ -11,10 +14,23 @@ @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"); + response.setContentType("application/json; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized - response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"로그인이 필요합니다.\"}"); + + // BaseResponse 형식으로 응답 생성 + BaseResponse baseResponse = BaseResponse.onFailure("COMMON401", GlobalErrorStatus._UNAUTHORIZED.getMessage(), null); + + // JSON 응답 작성 + String jsonResponse = objectMapper.writeValueAsString(baseResponse); + response.getWriter().write(jsonResponse); } } From 0a9d7894f30642456ff1d72c098d18f72ea2e324 Mon Sep 17 00:00:00 2001 From: Park Dongkyu <86235780+dong99u@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:23:51 +0900 Subject: [PATCH 057/281] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20@AuthenticationPri?= =?UTF-8?q?ncipal=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20Refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 윤서님 코드와 통합 및 소셜 로그인 할 때 직접 MemberId를 보내는 대신에 @AuthenticationPrincipal을 사용해서 보안성을 높이는 방향으로 리팩터링함. --- .../auth/controller/AuthController.java | 429 +++++++++++++++--- .../auth/controller/OAuth2Controller.java | 63 +-- .../request/MemberRegistrationRequest.java | 7 - .../auth/handler/OAuth2SuccessHandler.java | 10 +- .../auth/jwt/JwtAuthenticationFilter.java | 34 +- .../auth/security/CustomUserDetails.java | 93 ++-- .../security/CustomUserDetailsService.java | 31 +- .../auth/security/OAuth2UserService.java | 9 +- .../member/converter/MemberConverter.java | 26 +- .../mody/domain/member/entity/Member.java | 18 +- .../mody/domain/member/enums/LoginType.java | 5 + .../member/repository/MemberRepository.java | 8 + .../member/service/MemberCommandService.java | 2 +- .../service/MemberCommandServiceImpl.java | 9 +- .../code/status/MemberErrorStatus.java | 4 +- .../mody/global/config/SecurityConfig.java | 39 +- 16 files changed, 544 insertions(+), 243 deletions(-) create mode 100644 src/main/java/com/example/mody/domain/member/enums/LoginType.java 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 index dc492f3..b57244c 100644 --- a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java @@ -1,29 +1,36 @@ package com.example.mody.domain.auth.controller; -import com.example.mody.domain.auth.dto.request.MemberJoinRequest; -import com.example.mody.domain.auth.dto.request.MemberLoginReqeust; import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +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.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.security.CustomUserDetails; import com.example.mody.domain.auth.service.AuthCommandService; import com.example.mody.domain.member.service.MemberCommandService; 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.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") +@Tag(name = "Auth API", description = "인증 관련 API - 회원가입, 로그인, 토큰 재발급, 로그아웃 등의 기능을 제공합니다.") @RestController @RequiredArgsConstructor @RequestMapping("/auth") @@ -32,79 +39,249 @@ public class AuthController { private final AuthCommandService authCommandService; private final MemberCommandService memberCommandService; - /** - * 회원가입 완료 API - * @param request - * @return - */ - @Operation(summary = "카카오로그인 회원가입 완료 API", description = "소셜 로그인 후 추가 정보를 입력받아 회원가입을 완료하는 API") - @PostMapping("/signup/oauth2") + @Operation( + summary = "소셜 로그인 회원가입 완료", + description = """ + 소셜 로그인 후 추가 정보를 입력받아 회원가입을 완료합니다. + 카카오 로그인 성공 후 신규 회원인 경우 호출해야 하는 API입니다. + """, + tags = {"회원가입"} + ) @ApiResponses({ @ApiResponse( responseCode = "200", - description = "토큰 재발급 성공", - content = @Content(schema = @Schema(implementation = BaseResponse.class)) + 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/oauth2") public BaseResponse completeRegistration( - @Valid @RequestBody MemberRegistrationRequest request) { - memberCommandService.completeRegistration(request); + @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); } - /** - * 토큰 재발급 API - * @param refreshToken - * @param response - * @return - */ - @Operation(summary = "토큰 재발급 API", description = "Refresh Token으로 새로운 Access Token을 발급받는 API") + @Operation( + summary = "토큰 재발급", + description = """ + Access Token이 만료되었을 때 Refresh Token을 사용하여 새로운 토큰을 발급받습니다. + Refresh Token은 쿠키에서 자동으로 추출되며, 새로운 Access Token과 Refresh Token이 발급됩니다. + 발급된 Access Token은 응답 헤더의 Authorization에, Refresh Token은 쿠키에 포함됩니다. + """, + tags = {"인증", "토큰"} + ) @ApiResponses({ @ApiResponse( responseCode = "200", description = "토큰 재발급 성공", - content = @Content(schema = @Schema(implementation = BaseResponse.class)) - ) - }) - @Parameters({ - @Parameter( - name = "refresh_token", - description = "리프레시 토큰 (쿠키)", - required = true, - schema = @Schema(type = "string") + 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 = "401", + description = "유효하지 않거나 만료된 Refresh Token", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "AUTH006", + "message": "유효하지 않은 REFRESH TOKEN입니다.", + "result": null + } + """ + ) + ) ) }) @PostMapping("/reissue") - public ResponseEntity reissueToken(@CookieValue(name = "refresh_token") String refreshToken, - HttpServletResponse response) { - - // 토큰 재발급을 서비스 단에서 수행하도록 함. + public BaseResponse reissueToken( + @CookieValue(name = "refresh_token") + @Parameter( + description = "리프레시 토큰 (쿠키에서 자동 추출)", + required = true + ) String refreshToken, + HttpServletResponse response + ) { authCommandService.reissueToken(refreshToken, response); - return ResponseEntity.ok().build(); + return BaseResponse.onSuccess(null); } - @Operation(summary = "로그아웃 API", description = "로그아웃하고 Refresh Token을 제거하는 API") + @Operation( + summary = "로그아웃", + description = """ + 사용자를 로그아웃 처리합니다. + 서버에서 Refresh Token을 삭제하고, 클라이언트의 쿠키에서도 Refresh Token을 제거합니다. + 클라이언트에서는 저장된 Access Token도 함께 삭제해야 합니다. + """, + tags = {"인증"}, + security = @SecurityRequirement(name = "Bearer Authentication") + ) @ApiResponses({ @ApiResponse( responseCode = "200", description = "로그아웃 성공", - content = @Content(schema = @Schema(implementation = BaseResponse.class)) - ) - }) - @Parameters({ - @Parameter( - name = "refresh_token", - description = "리프레시 토큰 (쿠키)", - required = true, - schema = @Schema(type = "string") + 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 = "401", + description = "인증되지 않은 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "AUTH001", + "message": "JWT가 없습니다.", + "result": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "유효하지 않은 Refresh Token", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "AUTH006", + "message": "유효하지 않은 REFRESH TOKEN입니다.", + "result": null + } + """ + ) + ) ) }) @PostMapping("/logout") - public ResponseEntity logout(@CookieValue(name = "refresh_token") String refreshToken, - HttpServletResponse response) { + 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) @@ -114,25 +291,153 @@ public ResponseEntity logout(@CookieValue(name = "refresh_token") String r .build(); response.setHeader("Set-Cookie", refreshTokenCookie.toString()); - return ResponseEntity.ok().build(); + return BaseResponse.onSuccess(null); } - @Operation(summary = "회원가입 API") + @Operation( + summary = "일반 회원가입 API", + description = "이메일과 비밀번호를 사용하여 새로운 회원을 등록합니다.", + tags = {"회원가입"} + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "회원가입 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class) + ) + ), + @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 = "409", + description = "이미 존재하는 이메일", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "MEMBER409", + "message": "이미 등록된 이메일입니다.", + "result": null + } + """ + ) + ) + ) + }) @PostMapping("/signup") - public BaseResponse joinMember(@Valid @RequestBody MemberJoinRequest request) { - + 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 + ) { memberCommandService.joinMember(request); return BaseResponse.onSuccess(null); } - @Operation(summary = "로그인 API", description = "사용자의 이메일과 비밀번호를 통해 JWT Access Token과 Refresh Token을 발급받습니다.") - @PostMapping("/login") + /** + * Swagger 명세를 위한 API + * @param loginReqeust + * @return + */ + @Operation( + summary = "로그인 API", + description = "이메일과 비밀번호를 사용하여 로그인합니다. 성공 시 Access Token과 Refresh Token이 발급됩니다.", + tags = {"인증", "로그인"} + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), - @ApiResponse(responseCode = "401", description = "잘못된 이메일 또는 비밀번호") + @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 = "401", + description = "로그인 실패", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2024-01-13T10:00:00", + "code": "AUTH401", + "message": "이메일 또는 비밀번호가 일치하지 않습니다.", + "result": null + } + """ + ) + ) + ) }) - public ResponseEntity authLogin(@RequestBody @Valid MemberLoginReqeust loginReqeust) { - return ResponseEntity.ok().build(); - } + @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 loginReqeust + ) { + return BaseResponse.onSuccess(null); + } } \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java b/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java index a0fffe0..cb826fa 100644 --- a/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java +++ b/src/main/java/com/example/mody/domain/auth/controller/OAuth2Controller.java @@ -1,16 +1,13 @@ package com.example.mody.domain.auth.controller; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Hidden; 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.headers.Header; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -27,57 +24,33 @@ public class OAuth2Controller { @Operation( summary = "카카오 로그인 시작", - description = "카카오 로그인 페이지로 리다이렉트됩니다. " + - "프론트엔드에서는 window.location.href를 사용하여 이 URL로 이동해야 합니다." + description = """ + 카카오 로그인 프로세스를 시작합니다. + 프론트엔드에서는 이 URL로 리다이렉트하여 카카오 로그인을 시작해야 합니다. + 스웨거에서는 리다이렉트를 지원하지 않기 때문에, 프론트엔드에서 직접 리다이렉트해야 합니다. + 예시: window.location.href = 'http://your-domain/oauth2/authorization/kakao'; + """, + tags = {"소셜 로그인"} ) @ApiResponses({ @ApiResponse( responseCode = "302", description = "카카오 로그인 페이지로 리다이렉트", - content = @Content( - mediaType = "text/html", - examples = @ExampleObject( - value = "카카오 로그인 페이지로 리다이렉트됩니다." - ) + headers = @Header( + name = "Location", + description = "카카오 로그인 페이지 URL", + schema = @Schema(type = "string") ) ) }) @GetMapping("/authorization/kakao") - public ResponseEntity kakaoLogin() { - return ResponseEntity.ok().build(); + public void kakaoLogin() { + // Spring Security에서 자동으로 처리 } - @Operation( - summary = "카카오 로그인 콜백", - description = "카카오 인증 완료 후 리다이렉트되는 엔드포인트입니다. " + - "인증 코드를 받아 처리 후, 액세스 토큰과 리프레시 토큰을 발급합니다." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "로그인 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = com.example.mody.domain.auth.dto.response.LoginResponse.class) - ) - ), - @ApiResponse( - responseCode = "401", - description = "인증 실패" - ) - }) - @Parameters({ - @Parameter( - name = "code", - description = "카카오에서 발급된 인증 코드", - required = true, - schema = @Schema(type = "string") - ) - }) + @Hidden // 실제 구현이 필요없는 콜백 엔드포인트는 문서에서 숨김 @GetMapping("/callback/kakao") - public ResponseEntity kakaoCallback( - @RequestParam String code - ) { - return ResponseEntity.ok().build(); + public void kakaoCallback(@RequestParam String code) { + // Spring Security에서 자동으로 처리 } } \ No newline at end of file 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 index c4ad44d..0ae4501 100644 --- 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 @@ -20,13 +20,6 @@ @NoArgsConstructor public class MemberRegistrationRequest { - @Schema( - description = "회원 ID", - example = "1" - ) - @NotNull(message = "OAuth2 제공자의 고유 ID는 필수입니다") - private Long memberId; - @Schema( description = "닉네임", example = "모디", 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 index a999d2e..e23a733 100644 --- a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java @@ -23,7 +23,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Component @RequiredArgsConstructor public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { @@ -54,8 +56,9 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo boolean isNewMember = member.getCreatedAt().equals(member.getUpdatedAt()); // Access Token, Refresh Token 발급 - String accessToken = jwtProvider.createAccessToken(member.getProviderId()); - String refreshToken = jwtProvider.createRefreshToken(member.getProviderId()); + // 사용자의 ID를 기반으로 Access Token, Refresh Token 생성 + String accessToken = jwtProvider.createAccessToken(member.getId().toString()); + String refreshToken = jwtProvider.createRefreshToken(member.getId().toString()); // Refresh Token 저장 authCommandService.saveRefreshToken(member, refreshToken); @@ -70,7 +73,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo .build(); // Access Token을 Authorization 헤더에 설정 - response.setHeader("Authorization", "Bearer " + accessToken); + response.addHeader("Authorization", "Bearer " + accessToken); response.setHeader("Set-Cookie", refreshTokenCookie.toString()); // 로그인 응답 데이터 설정 @@ -85,6 +88,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(BaseResponse.onSuccess(loginResponse))); + } private Member saveMember(CustomOAuth2User oAuth2User) { 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 index 8dfe4b0..e730498 100644 --- a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -3,11 +3,7 @@ import java.io.IOException; import java.util.List; import java.util.Map; -import java.util.Optional; -import com.example.mody.domain.auth.security.CustomUserDetails; -import com.example.mody.domain.member.service.MemberCommandService; -import com.example.mody.domain.member.service.MemberQueryService; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -16,8 +12,10 @@ 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; @@ -55,35 +53,17 @@ protected void doFilterInternal( // 만약 토큰이 있다면 if (token != null) { - /* + String memberId = jwtProvider.validateTokenAndGetSubject(token); + Member member = memberRepository.findById(Long.parseLong(memberId)) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)); - String providerId = jwtProvider.validateTokenAndGetSubject(token); - // findByProviderId로 Member 찾기 - Member member = memberRepository.findByProviderId(providerId) - .orElseGet(() -> - // providerId로 찾지 못했을 경우, findByEmail로 조회 - memberRepository.findByEmail(providerId) - .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)) - ); + CustomUserDetails customUserDetails = new CustomUserDetails(member); Authentication authentication = new UsernamePasswordAuthenticationToken( - member.getId(), + customUserDetails, null, List.of(new SimpleGrantedAuthority(member.getRole().toString())) ); - */ - - // 토큰 검증 및 추출 - String payload = jwtProvider.validateTokenAndGetSubject(token); - Member member = memberQueryService.findMemberById(Long.parseLong(payload)); - CustomUserDetails customUserDetails = new CustomUserDetails(member); - - Authentication authentication = new UsernamePasswordAuthenticationToken( - customUserDetails, - null, - List.of(new SimpleGrantedAuthority(member.getRole().toString())) - ); - SecurityContextHolder.getContext().setAuthentication(authentication); } 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 index b654dba..7aa8736 100644 --- a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetails.java @@ -1,67 +1,70 @@ package com.example.mody.domain.auth.security; -import com.example.mody.domain.member.entity.Member; -import lombok.RequiredArgsConstructor; +import java.util.ArrayList; +import java.util.Collection; + import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.ArrayList; -import java.util.Collection; -import java.util.concurrent.ArrayBlockingQueue; +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; + private final Member member; - //Role return - @Override - public Collection getAuthorities() { + //Role return + @Override + public Collection getAuthorities() { - Collection collection = new ArrayList<>(); + Collection collection = new ArrayList<>(); - collection.add(new GrantedAuthority() { - @Override - public String getAuthority() { + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { - return member.getRole().name(); - } - }); + return member.getRole().name(); + } + }); - return collection; - } + return collection; + } - @Override - public String getPassword() { - return member.getPassword(); - } + @Override + public String getPassword() { + return member.getPassword(); + } - @Override - public String getUsername() { - return member.getEmail(); - } + @Override + public String getUsername() { + return member.getEmail(); + } - @Override - public boolean isAccountNonExpired() { - return true; - } + @Override + public boolean isAccountNonExpired() { + return true; + } - @Override - public boolean isAccountNonLocked() { - return true; - } + @Override + public boolean isAccountNonLocked() { + return true; + } - @Override - public boolean isCredentialsNonExpired() { - return true; - } + @Override + public boolean isCredentialsNonExpired() { + return true; + } - @Override - public boolean isEnabled() { - return true; - } + @Override + public boolean isEnabled() { + return true; + } - public Member getMember(){ - return this.member; - } + 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 index 5cf8bcb..a365ef6 100644 --- a/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java +++ b/src/main/java/com/example/mody/domain/auth/security/CustomUserDetailsService.java @@ -1,28 +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; -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; @Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { - private final MemberRepository memberRepository; + private final MemberRepository memberRepository; + + //데이터베이스에서 사용자의 정보를 조회 + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - //데이터베이스에서 사용자의 정보를 조회 - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); - 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); - } + 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 index 485c7c1..b68246a 100644 --- a/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java +++ b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java @@ -9,6 +9,7 @@ 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; @@ -43,7 +44,10 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic // 회원 정보를 조회. // 만약 회원이 없다면 회원을 저장함. // 그 다음 AuthController의 회원가입 API를 통해 회원가입을 완료해야 함. - Member member = memberRepository.findByProviderId(oAuth2Response.getProviderId()) + Member member = memberRepository.findByProviderIdAndLoginType( + oAuth2Response.getProviderId(), + LoginType.KAKAO + ) .orElseGet(() -> saveMember(oAuth2Response)); return new CustomOAuth2User(oAuth2Response, oAuth2User.getAttributes(), member.isRegistrationCompleted()); @@ -56,7 +60,8 @@ private Member saveMember(OAuth2Response oAuth2Response) { .nickname(oAuth2Response.getName()) .status(Status.ACTIVE) .role(Role.ROLE_USER) - .isRegistrationCompleted(false) // 최초 가입 시 미완료 상태로 설정 + .loginType(LoginType.KAKAO) + .isRegistrationCompleted(false) .build(); return memberRepository.save(member); diff --git a/src/main/java/com/example/mody/domain/member/converter/MemberConverter.java b/src/main/java/com/example/mody/domain/member/converter/MemberConverter.java index 1806919..f5401f7 100644 --- a/src/main/java/com/example/mody/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/example/mody/domain/member/converter/MemberConverter.java @@ -2,22 +2,24 @@ import com.example.mody.domain.auth.dto.request.MemberJoinRequest; 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; public class MemberConverter { - public static Member toMember(MemberJoinRequest request, String email) { + public static Member toMember(MemberJoinRequest request, String email) { - return Member.builder() - .email(email) - .password(request.getPassword()) - .nickname(request.getNickname()) - .gender(request.getGender()) - .height(request.getHeight()) - .status(Status.ACTIVE) - .role(Role.ROLE_USER) - .isRegistrationCompleted(true) - .build(); - } + return Member.builder() + .email(email) + .password(request.getPassword()) + .nickname(request.getNickname()) + .gender(request.getGender()) + .height(request.getHeight()) + .status(Status.ACTIVE) + .role(Role.ROLE_USER) + .loginType(LoginType.GENERAL) + .isRegistrationCompleted(true) + .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 index 2768a80..49f0ce3 100644 --- a/src/main/java/com/example/mody/domain/member/entity/Member.java +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -4,16 +4,27 @@ import java.util.ArrayList; import java.util.List; -import com.example.mody.domain.bodytype.entity.MemberBodyType; -import jakarta.persistence.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; +import com.example.mody.domain.bodytype.entity.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.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; @@ -64,6 +75,9 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private Role role; + @Enumerated(EnumType.STRING) + private LoginType loginType; + @Builder.Default private boolean isRegistrationCompleted = false; // 회원가입 완료 여부 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 0000000..d5e0d84 --- /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/repository/MemberRepository.java b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java index ab1c3a9..dc628fa 100644 --- a/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/mody/domain/member/repository/MemberRepository.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Repository; import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.enums.LoginType; @Repository public interface MemberRepository extends JpaRepository { @@ -14,4 +15,11 @@ public interface MemberRepository extends JpaRepository { 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); + } 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 index 87c3a5f..1726ae3 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java @@ -4,7 +4,7 @@ import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; public interface MemberCommandService { - void completeRegistration(MemberRegistrationRequest request); + void completeRegistration(Long memberId, MemberRegistrationRequest request); void joinMember(MemberJoinRequest request); } 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 index 516b429..9211cd9 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java @@ -1,13 +1,13 @@ package com.example.mody.domain.member.service; -import com.example.mody.domain.auth.dto.request.MemberJoinRequest; -import com.example.mody.domain.member.converter.MemberConverter; 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.exception.MemberException; +import com.example.mody.domain.member.converter.MemberConverter; 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; @@ -23,8 +23,8 @@ public class MemberCommandServiceImpl implements MemberCommandService { private final PasswordEncoder passwordEncoder; @Override - public void completeRegistration(MemberRegistrationRequest request) { - Member member = memberRepository.findById(request.getMemberId()) + public void completeRegistration(Long memberId, MemberRegistrationRequest request) { + Member member = memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); member.completeRegistration( @@ -44,7 +44,6 @@ public void joinMember(MemberJoinRequest request) { //이미 존재하는 회원인 경우 예외처리 if (isExist) { - throw new MemberException(MemberErrorStatus.EMAIL_ALREADY_EXISTS); } 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 index d807e25..26f6bb8 100644 --- 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 @@ -13,8 +13,8 @@ public enum MemberErrorStatus implements BaseCodeInterface { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", "해당 회원을 찾을 수 없습니다."), - EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "EMAIL409", "이미 존재하는 이메일입니다.") - ; + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "EMAIL409", "이미 존재하는 이메일입니다."), + INVALID_LOGIN_TYPE(HttpStatus.BAD_REQUEST, "MEMBER400", "잘못된 로그인 방식입니다."); private final HttpStatus httpStatus; private final boolean isSuccess = false; diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index f4d1d09..0b8e842 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -1,12 +1,8 @@ package com.example.mody.global.config; +import java.util.Arrays; import java.util.Collections; -import com.example.mody.domain.auth.jwt.JwtLoginFilter; -import com.example.mody.domain.auth.service.AuthCommandService; -import com.example.mody.domain.auth.service.AuthCommandServiceImpl; -import com.example.mody.domain.member.service.MemberCommandService; -import com.example.mody.domain.member.service.MemberQueryService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -23,14 +19,20 @@ 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.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -62,37 +64,38 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .userService(oAuth2UserService) ) .successHandler(oAuth2SuccessHandler) + .failureHandler((request, response, exception) -> { + log.error("OAuth2 로그인 실패: ", exception); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "OAuth2 로그인 실패"); + }) ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); http .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { - @Override public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { - CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); - configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders( + Arrays.asList("Authorization", "Content-Type", "X-Requested-With", "Accept")); + configuration.setExposedHeaders(Arrays.asList("Authorization", "Set-Cookie")); configuration.setAllowCredentials(true); - configuration.setAllowedHeaders(Collections.singletonList("*")); configuration.setMaxAge(3600L); - configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); - configuration.setExposedHeaders(Collections.singletonList("Authorization")); - return configuration; } })); - //로그인 필터 등록 JwtLoginFilter jwtLoginFilter = new JwtLoginFilter( - authenticationManager(authenticationConfiguration), - jwtProvider, - authCommandService, - memberRepository, - objectMapper + authenticationManager(authenticationConfiguration), + jwtProvider, + authCommandService, + memberRepository, + objectMapper ); jwtLoginFilter.setFilterProcessesUrl("/auth/login"); http.addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class); From 2f88292b7ded7f3ee5656bf3ad8c6e8ca5f42d9c Mon Sep 17 00:00:00 2001 From: sshnote Date: Tue, 14 Jan 2025 15:57:14 +0900 Subject: [PATCH 058/281] =?UTF-8?q?=E2=9C=A8=20[#25]=20feature:=20?= =?UTF-8?q?=EC=B2=B4=ED=98=95=20=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 체형 분석 결과와 관련된 API 구현입니다. --- .../controller/BodyTypeController.java | 81 +++++++++++++++++-- .../mody/domain/bodytype/entity/BodyType.java | 2 +- .../entity/mapping/MemberBodyType.java | 6 ++ .../repository/BodyTypeRepository.java | 3 + .../repository/MemberBodyTypeRepository.java | 2 + .../bodytype/BodyTypeQueryService.java | 7 ++ .../bodytype/BodyTypeQueryServiceImpl.java | 25 ++++++ .../MemberBodyTypeCommandService.java | 7 ++ .../MemberBodyTypeCommandServiceImpl.java | 20 +++++ .../MemberBodyTypeQueryService.java | 11 +++ .../MemberBodyTypeQueryServiceImpl.java | 43 ++++++++++ .../chatgpt/service/ChatGptService.java | 37 ++++++--- .../code/status/BodyTypeErrorStatus.java | 2 + .../mody/global/config/SecurityConfig.java | 2 +- .../mody/global/templates/PromptManager.java | 11 +-- 15 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryService.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java create mode 100644 src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java 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 index c4d8d56..894eaeb 100644 --- a/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java +++ b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java @@ -1,14 +1,27 @@ package com.example.mody.domain.bodytype.controller; import com.example.mody.domain.bodytype.dto.BodyTypeAnalysisResponse; +import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.bodytype.service.bodytype.BodyTypeQueryService; +import com.example.mody.domain.bodytype.service.memberbodytype.MemberBodyTypeCommandService; +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.member.enums.Gender; +import com.example.mody.domain.member.service.MemberQueryService; import com.example.mody.global.common.base.BaseResponse; +import com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; 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.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -19,21 +32,73 @@ public class BodyTypeController { private final ChatGptService chatGptService; + private final MemberQueryService memberQueryService; + private final MemberBodyTypeCommandService memberBodyTypeCommandService; + private final BodyTypeQueryService bodyTypeQueryService; + private final MemberBodyTypeQueryService memberBodyTypeQueryService; - @PostMapping("/result") - @Operation(summary = "체형 분석 API", description = "OpenAi를 사용해서 체형을 분석하는 API입니다.") + @PostMapping("/{memberId}") + @Operation(summary = "체형 분석 API", description = "OpenAi를 사용해서 사용자의 체형을 분석하는 API입니다. Request Body에는 질문에 맞는 답변 목록을 String으로 보내주세요.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공") }) @Parameters({ - @Parameter(name = "name", description = "사용자 이름", required = true), - @Parameter(name = "gender", description = "사용자 성별", required = true) + @Parameter(name = "memberId", description = "체형 분석을 진행할 사용자의 member_id 값을 path variable로 보내주세요.", required = true) }) public BaseResponse analyzeBodyType( - @RequestParam @NotBlank(message = "query string으로, 이름을 필수로 입력해 주세요!") String name, - @RequestParam @NotBlank(message = "query string으로, 성별을 필수로 입력해 주세요!") String gender, - @RequestBody @NotBlank(message = "request body로, 질문에 맞는 답변 목록을 String으로 보내주세요!") String answers + @NotNull @PathVariable Long memberId, + @RequestBody @NotBlank String answers ) { - return BaseResponse.onSuccess(chatGptService.analyzeBodyType(name, gender, answers)); + // id에 맞는 사용자 조회 + Member member = memberQueryService.findMemberById(memberId); + + // 사용자 정보(닉네임, 성별) 조회 + String nickname = member.getNickname(); + Gender gender = member.getGender(); + + // OpenAi API를 통한 체형 분석 + String content = chatGptService.getContent(nickname, gender, answers); + BodyTypeAnalysisResponse bodyTypeAnalysisResponse = chatGptService.analyzeBodyType(content); + + // MemberBodyType을 DB에 저장 + saveMemberBodyType(content, bodyTypeAnalysisResponse, member); + + return BaseResponse.onSuccess(bodyTypeAnalysisResponse); + } + + private void saveMemberBodyType(String content, BodyTypeAnalysisResponse bodyTypeAnalysisResponse, Member member) { + String bodyTypeName = bodyTypeAnalysisResponse.getBodyTypeAnalysis().getType(); + BodyType bodyType = bodyTypeQueryService.findByBodyTypeName(bodyTypeName); + MemberBodyType memberBodyType = new MemberBodyType(content, member, bodyType); + memberBodyTypeCommandService.saveMemberBodyType(memberBodyType); + } + + @GetMapping + @Operation(summary = "체형 질문 문항 조회 API - 프론트와 협의 필요", + description = "체형 분석을 하기 위해 질문 문항을 받아 오는 API입니다. 이 부분은 서버에서 바로 프롬프트로 넣는 방법도 있기 때문에 프론트와 협의 후 진행하겠습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공") + }) + public BaseResponse getQuestion() { + return BaseResponse.onSuccess(null); + } + + @GetMapping("/{memberId}") + @Operation(summary = "체형 분석 결과물 조회 API", description = "체형 분석 결과물을 받아 오는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공") + }) + @Parameters({ + @Parameter(name = "memberId", description = "체형 분석 결과를 받아올 사용자의 member_id 값을 path variable로 보내주세요.", required = true) + }) + public BaseResponse getBodyType(@NotNull @PathVariable Long memberId) { + // id에 맞는 사용자 조회 + Member member = memberQueryService.findMemberById(memberId); + + // 사용자에 맞는 체형 분석 내용 조회 + MemberBodyType memberBodyType = memberBodyTypeQueryService.findMemberBodyTypeByMember(member); + String body = memberBodyType.getBody(); + + return BaseResponse.onSuccess(memberBodyTypeQueryService.getBodyTypeAnalysis(body)); } } \ No newline at end of file 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 index 9d959db..69ce10d 100644 --- a/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java +++ b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java @@ -25,7 +25,7 @@ public class BodyType extends BaseEntity { private Long id; @Column(nullable = false, length = 30) - private String name; + private String name; // 네추럴, 스트레이트, 웨이브 @OneToMany(mappedBy = "bodyType", cascade = CascadeType.ALL) private List memberBodyTypeList = new ArrayList<>(); 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 index fc5bd69..3717131 100644 --- 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 @@ -32,4 +32,10 @@ public class MemberBodyType extends BaseEntity { @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 index fff4eed..6fd0da1 100644 --- a/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java +++ b/src/main/java/com/example/mody/domain/bodytype/repository/BodyTypeRepository.java @@ -4,6 +4,9 @@ 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 index 0eff484..5811d74 100644 --- a/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java +++ b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java @@ -10,4 +10,6 @@ @Repository public interface MemberBodyTypeRepository extends JpaRepository { Optional findTopByMemberOrderByCreatedAt(Member member); + + Optional findMemberBodyTypeByMember(Member member); } 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 0000000..f391f5b --- /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 0000000..b5d66e5 --- /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 0000000..7621f2a --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.bodytype.service.memberbodytype; + +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; + +public interface MemberBodyTypeCommandService { + public void saveMemberBodyType(MemberBodyType memberBodyType); +} 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 0000000..e080ed8 --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java @@ -0,0 +1,20 @@ +package com.example.mody.domain.bodytype.service.memberbodytype; + +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.bodytype.repository.MemberBodyTypeRepository; +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; + + @Override + public void saveMemberBodyType(MemberBodyType memberBodyType) { + memberBodyTypeRepository.save(memberBodyType); + } +} 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 0000000..3ba61f7 --- /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.BodyTypeAnalysisResponse; +import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.member.entity.Member; + +public interface MemberBodyTypeQueryService { + public MemberBodyType findMemberBodyTypeByMember(Member member); + + public BodyTypeAnalysisResponse getBodyTypeAnalysis(String body); +} 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 0000000..9090628 --- /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.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.base.BaseResponse; +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(); + + // Member로 MemberBodyType 조회 + @Override + public MemberBodyType findMemberBodyTypeByMember(Member member) { + Optional optionalMemberBodyType = memberBodyTypeRepository.findMemberBodyTypeByMember(member); + return optionalMemberBodyType.orElseThrow(()-> new BodyTypeException(BodyTypeErrorStatus.MEMBER_BODY_TYPE_NOT_FOUND)); + } + + // MemberBodyType의 body 내용을 BodyTypeAnalysisResponse로 변환 + @Override + public BodyTypeAnalysisResponse getBodyTypeAnalysis(String body) { + // String -> DTO 변환 + try { + return objectMapper.readValue(body, BodyTypeAnalysisResponse.class); + } catch (JsonProcessingException e) { + throw new BodyTypeException(BodyTypeErrorStatus.JSON_PARSING_ERROR); + } + } +} 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 index 9b33008..6251be7 100644 --- a/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java +++ b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java @@ -1,6 +1,7 @@ package com.example.mody.domain.chatgpt.service; import com.example.mody.domain.bodytype.dto.BodyTypeAnalysisResponse; +import com.example.mody.domain.member.enums.Gender; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import com.example.mody.global.common.exception.RestApiException; @@ -43,11 +44,26 @@ public final class ChatGptService { private final String userRole = "user"; // 대화에서의 역할(사용자 메시지) - // 체형 분석 메서드 - public BodyTypeAnalysisResponse analyzeBodyType(String name, String gender, String answers) { + // 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(name, gender, answers); + String prompt = promptManager.createBodyTypeAnalysisPrompt(nickName, gender, answers); // OpenAI API 호출 ChatGPTResponse response = openAiApiClient.sendRequestToModel( @@ -58,16 +74,11 @@ public BodyTypeAnalysisResponse analyzeBodyType(String name, String gender, Stri maxTokens, temperature ); + return response; + } - // 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); + // 체형 분석 메서드 + public BodyTypeAnalysisResponse analyzeBodyType(String content) { try { // content -> BodyTypeAnalysisResponse 객체로 변환 @@ -78,4 +89,6 @@ public BodyTypeAnalysisResponse analyzeBodyType(String name, String gender, Stri throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); } } + + } \ No newline at end of file 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 index fe4d20c..1c473e4 100644 --- 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 @@ -11,6 +11,8 @@ public enum BodyTypeErrorStatus implements BaseCodeInterface { MEMBER_BODY_TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_BODY_TYPE404", "체형 분석 결과를 찾을 수 없습니다."), + BODY_TYPE_NOT_FOUND(HttpStatus.BAD_REQUEST, "BODY_TYPE4001", "체형을 찾을 수 없습니다."), + JSON_PARSING_ERROR(HttpStatus.BAD_REQUEST, "JSON_PARSING4001", "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index 3c60828..dbfd8b9 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -54,7 +54,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authz -> authz .requestMatchers("/auth/**", "/oauth2/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() - .requestMatchers("/body-analysis/result").permitAll() // 체형 분석 테스트용 + .requestMatchers("/body-analysis/{memberId}").permitAll() // 체형 분석 테스트용 .anyRequest().authenticated() ) diff --git a/src/main/java/com/example/mody/global/templates/PromptManager.java b/src/main/java/com/example/mody/global/templates/PromptManager.java index ea288ca..2aee519 100644 --- a/src/main/java/com/example/mody/global/templates/PromptManager.java +++ b/src/main/java/com/example/mody/global/templates/PromptManager.java @@ -1,5 +1,6 @@ package com.example.mody.global.templates; +import com.example.mody.domain.member.enums.Gender; import org.springframework.stereotype.Component; /** @@ -9,24 +10,24 @@ public class PromptManager { // 체형 분석 프롬프트 생성 메서드 - public String createBodyTypeAnalysisPrompt(String name, String gender, String answers) { + public String createBodyTypeAnalysisPrompt(String nickName, Gender gender, String answers) { PromptTemplate template = new PromptTemplate(); return template.fillTemplate( """ ## 명령 - 이름과 성별, 그리고 사용자의 답변을 기반으로 체형 타입(네추럴, 스트레이트, 웨이브 중 하나)을 분석하고, 설명과 스타일링 팁을 제공해줘. + 닉네임과 성별, 그리고 사용자의 답변을 기반으로 체형 타입(네추럴, 스트레이트, 웨이브 중 하나)을 분석하고, 설명과 스타일링 팁을 제공해줘. 결과는 JSON 형식으로 반환해줘. ## 사용자 정보 - 이름: %s + 닉네임: %s 성별: %s ## 답변 %s - """.formatted(name, gender, answers), + """.formatted(nickName, gender, answers), """ { - "name": "<사용자 이름>", + "name": "<사용자 닉네임>", "bodyTypeAnalysis": { "type": "<체형 유형>", "description": "<체형 설명>", From 21756aa95b421bf24d48d3428899da9a3fe80233 Mon Sep 17 00:00:00 2001 From: yunseo02 <154687627+yunseo02@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:21:44 +0900 Subject: [PATCH 059/281] =?UTF-8?q?=E2=9C=A8=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpt로 스타일 분석 후 결과를 반환하는 api --- .../mody/domain/member/entity/QMember.java | 3 + .../mody/domain/style/entity/QStyle.java | 73 +++++++++++++++++ .../mody/domain/style/entity/QStyleImage.java | 64 +++++++++++++++ .../style/entity/category/QStyleCategory.java | 50 ++++++++++++ .../mody/domain/member/entity/Member.java | 15 ++-- .../style/controller/StyleController.java | 39 +++++++++ .../dto/request/StyleRecommendRequest.java | 35 ++++++++ .../style/dto/response/CategoryResponse.java | 13 +++ .../dto/response/StyleRecommendResponse.java | 56 +++++++++++++ .../mody/domain/style/entity/Style.java | 43 ++++++++++ .../mody/domain/style/entity/StyleImage.java | 30 +++++++ .../style/entity/category/AppealCategory.java | 18 +++++ .../style/entity/category/StyleCategory.java | 18 +++++ .../style/repository/StyleRepository.java | 8 ++ .../domain/style/service/ChatGptService.java | 80 +++++++++++++++++++ .../style/service/StyleCommandService.java | 9 +++ .../service/StyleCommandServiceImpl.java | 30 +++++++ .../domain/style/templates/PromptManager.java | 45 +++++++++++ .../style/templates/PromptTemplate.java | 23 ++++++ .../code/status/AnalysisErrorStatus.java | 37 +++++++++ .../mody/global/config/OpenAiConfig.java | 26 ++++++ .../mody/global/config/SecurityConfig.java | 1 + .../infrastructure/ai/OpenAiApiClient.java | 27 +++++++ .../ai/dto/request/ChatGptRequest.java | 21 +++++ .../ai/dto/response/ChatGptResponse.java | 22 +++++ 25 files changed, 778 insertions(+), 8 deletions(-) create mode 100644 src/main/generated/com/example/mody/domain/style/entity/QStyle.java create mode 100644 src/main/generated/com/example/mody/domain/style/entity/QStyleImage.java create mode 100644 src/main/generated/com/example/mody/domain/style/entity/category/QStyleCategory.java create mode 100644 src/main/java/com/example/mody/domain/style/controller/StyleController.java create mode 100644 src/main/java/com/example/mody/domain/style/dto/request/StyleRecommendRequest.java create mode 100644 src/main/java/com/example/mody/domain/style/dto/response/CategoryResponse.java create mode 100644 src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendResponse.java create mode 100644 src/main/java/com/example/mody/domain/style/entity/Style.java create mode 100644 src/main/java/com/example/mody/domain/style/entity/StyleImage.java create mode 100644 src/main/java/com/example/mody/domain/style/entity/category/AppealCategory.java create mode 100644 src/main/java/com/example/mody/domain/style/entity/category/StyleCategory.java create mode 100644 src/main/java/com/example/mody/domain/style/repository/StyleRepository.java create mode 100644 src/main/java/com/example/mody/domain/style/service/ChatGptService.java create mode 100644 src/main/java/com/example/mody/domain/style/service/StyleCommandService.java create mode 100644 src/main/java/com/example/mody/domain/style/service/StyleCommandServiceImpl.java create mode 100644 src/main/java/com/example/mody/domain/style/templates/PromptManager.java create mode 100644 src/main/java/com/example/mody/domain/style/templates/PromptTemplate.java create mode 100644 src/main/java/com/example/mody/global/common/exception/code/status/AnalysisErrorStatus.java create mode 100644 src/main/java/com/example/mody/global/config/OpenAiConfig.java create mode 100644 src/main/java/com/example/mody/global/infrastructure/ai/OpenAiApiClient.java create mode 100644 src/main/java/com/example/mody/global/infrastructure/ai/dto/request/ChatGptRequest.java create mode 100644 src/main/java/com/example/mody/global/infrastructure/ai/dto/response/ChatGptResponse.java 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 index 33ae481..38af6be 100644 --- a/src/main/generated/com/example/mody/domain/member/entity/QMember.java +++ b/src/main/generated/com/example/mody/domain/member/entity/QMember.java @@ -7,6 +7,7 @@ import com.querydsl.core.types.PathMetadata; import javax.annotation.processing.Generated; import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; /** @@ -53,6 +54,8 @@ public class QMember extends EntityPathBase { public final EnumPath status = createEnum("status", com.example.mody.domain.member.enums.Status.class); + public final ListPath styles = this.createList("styles", com.example.mody.domain.style.entity.Style.class, com.example.mody.domain.style.entity.QStyle.class, PathInits.DIRECT2); + //inherited public final DateTimePath updatedAt = _super.updatedAt; diff --git a/src/main/generated/com/example/mody/domain/style/entity/QStyle.java b/src/main/generated/com/example/mody/domain/style/entity/QStyle.java new file mode 100644 index 0000000..f7fc937 --- /dev/null +++ b/src/main/generated/com/example/mody/domain/style/entity/QStyle.java @@ -0,0 +1,73 @@ +package com.example.mody.domain.style.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; + + +/** + * QStyle is a Querydsl query type for Style + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QStyle extends EntityPathBase + + +
+
+

이메일 인증

+
+
+

+

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

+ +
+ +

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

+ +
+

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

+

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

+
+
+ +
+ + \ No newline at end of file From cfb7617c3cfcb8d4d4808dab265eda02b9d44251 Mon Sep 17 00:00:00 2001 From: yunseo02 <154687627+yunseo02@users.noreply.github.com> Date: Thu, 30 Jan 2025 18:14:40 +0900 Subject: [PATCH 188/281] =?UTF-8?q?=E2=9C=A8=20[#87]=20feature:=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=EB=B0=9B=EC=9D=80=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추천받은 패션 아이템을 커서 페이징으로 조회하는 api입니다. --- .../chatgpt/service/ChatGptService.java | 6 +- .../controller/FashionItemController.java | 57 ----------------- .../controller/ItemController.java | 63 +++++++++++++++++++ ...mendResponse.java => ItemGptResponse.java} | 10 +-- .../dto/response/ItemRecommendResponse.java | 20 ++++++ ...nItemsResponse.java => ItemsResponse.java} | 29 ++++----- .../repository/FashionItemRepository.java | 7 --- .../repository/ItemRepository.java | 22 +++++++ .../service/FashionItemCommandService.java | 11 ---- .../service/ItemCommandService.java | 11 ++++ ...eImpl.java => ItemCommandServiceImpl.java} | 27 +++++--- .../fashionItem/service/ItemQueryService.java | 10 +++ .../service/ItemQueryServiceImpl.java | 45 +++++++++++++ .../global/dto/response/CursorResult.java | 12 ++++ 14 files changed, 216 insertions(+), 114 deletions(-) delete mode 100644 src/main/java/com/example/mody/domain/fashionItem/controller/FashionItemController.java create mode 100644 src/main/java/com/example/mody/domain/fashionItem/controller/ItemController.java rename src/main/java/com/example/mody/domain/fashionItem/dto/response/{FashionItemRecommendResponse.java => ItemGptResponse.java} (82%) create mode 100644 src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemRecommendResponse.java rename src/main/java/com/example/mody/domain/fashionItem/dto/response/{FashionItemsResponse.java => ItemsResponse.java} (72%) delete mode 100644 src/main/java/com/example/mody/domain/fashionItem/repository/FashionItemRepository.java create mode 100644 src/main/java/com/example/mody/domain/fashionItem/repository/ItemRepository.java delete mode 100644 src/main/java/com/example/mody/domain/fashionItem/service/FashionItemCommandService.java create mode 100644 src/main/java/com/example/mody/domain/fashionItem/service/ItemCommandService.java rename src/main/java/com/example/mody/domain/fashionItem/service/{FashionItemCommandServiceImpl.java => ItemCommandServiceImpl.java} (69%) create mode 100644 src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryService.java create mode 100644 src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryServiceImpl.java create mode 100644 src/main/java/com/example/mody/global/dto/response/CursorResult.java 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 index e8fabe6..afa08fd 100644 --- a/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java +++ b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java @@ -2,7 +2,7 @@ import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; import com.example.mody.domain.fashionItem.dto.request.FashionItemRequest; -import com.example.mody.domain.fashionItem.dto.response.FashionItemRecommendResponse; +import com.example.mody.domain.fashionItem.dto.response.ItemGptResponse; import com.example.mody.domain.member.enums.Gender; import com.example.mody.domain.style.dto.request.StyleRecommendRequest; import com.example.mody.domain.style.dto.response.StyleRecommendResponse; @@ -119,7 +119,7 @@ public StyleRecommendResponse.StyleRecommendation recommendGptStyle(StyleRecomme } } - public FashionItemRecommendResponse recommendGptItem(FashionItemRequest fashionItemRequest, String bodyType){ + public ItemGptResponse recommendGptItem(FashionItemRequest fashionItemRequest, String bodyType){ //아이템 추천 프롬프트 생성 String prompt = promptManager.createRecommendItemPrompt(bodyType, fashionItemRequest); @@ -135,7 +135,7 @@ public FashionItemRecommendResponse recommendGptItem(FashionItemRequest fashionI String content = response.getChoices().get(0).getMessage().getContent().trim(); try{ - return objectMapper.readValue(content, FashionItemRecommendResponse.class); + return objectMapper.readValue(content, ItemGptResponse.class); } catch (JsonMappingException e) { throw new RestApiException(AnalysisErrorStatus._GPT_ERROR); } catch (JsonProcessingException e) { diff --git a/src/main/java/com/example/mody/domain/fashionItem/controller/FashionItemController.java b/src/main/java/com/example/mody/domain/fashionItem/controller/FashionItemController.java deleted file mode 100644 index 767edf6..0000000 --- a/src/main/java/com/example/mody/domain/fashionItem/controller/FashionItemController.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.example.mody.domain.fashionItem.controller; - -import com.example.mody.domain.auth.security.CustomUserDetails; -import com.example.mody.domain.fashionItem.dto.request.FashionItemRequest; -import com.example.mody.domain.fashionItem.dto.response.FashionItemRecommendResponse; -import com.example.mody.domain.fashionItem.service.FashionItemCommandService; -import com.example.mody.global.common.base.BaseResponse; -import com.example.mody.global.util.CustomAuthenticationEntryPoint; -import io.swagger.v3.oas.annotations.Operation; -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("/fashion-item-analysis") -public class FashionItemController { - - private final FashionItemCommandService fashionItemCommandService; - -// @Operation(summary = "패션 아이템 추천 결과 조회 API", description = "사용자 본인의 패션 아이템 추천 결과를 조회합니다.") -// @GetMapping("/result") -// @ApiResponses({ -// @ApiResponse( -// responseCode = "200", -// description = "패션 아이템 추천 결과 조회 성공" -// ) -// }) -// public BaseResponse getRecommendedFashionItem( -// @AuthenticationPrincipal CustomUserDetails customUserDetails -// ) { -// return BaseResponse.onSuccess() -// } - - @Operation(summary = "패션 아이템 추천 API", description = "OpenAI를 통해 패션 아이템 추천을 받는 API입니다.") - @PostMapping("/result") - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "패션 아이템 추천 성공" - //content = - ) - }) - public BaseResponse recommendFashionItem( - @Valid @RequestBody FashionItemRequest request, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - FashionItemRecommendResponse response = fashionItemCommandService.recommendItem( - request, customUserDetails.getMember()); - return BaseResponse.onSuccess(response); - } - -} diff --git a/src/main/java/com/example/mody/domain/fashionItem/controller/ItemController.java b/src/main/java/com/example/mody/domain/fashionItem/controller/ItemController.java new file mode 100644 index 0000000..5007a47 --- /dev/null +++ b/src/main/java/com/example/mody/domain/fashionItem/controller/ItemController.java @@ -0,0 +1,63 @@ +package com.example.mody.domain.fashionItem.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.fashionItem.dto.request.FashionItemRequest; +import com.example.mody.domain.fashionItem.dto.response.ItemRecommendResponse; +import com.example.mody.domain.fashionItem.dto.response.ItemsResponse; +import com.example.mody.domain.fashionItem.service.ItemCommandService; +import com.example.mody.domain.fashionItem.service.ItemQueryService; +import com.example.mody.global.common.base.BaseResponse; +import com.example.mody.global.dto.response.CursorResult; +import io.swagger.v3.oas.annotations.Operation; +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("/fashion-item-analysis") +public class ItemController { + + private final ItemCommandService itemCommandService; + private final ItemQueryService itemQueryService; + + @Operation(summary = "패션 아이템 추천 API", description = "OpenAI를 통해 패션 아이템 추천을 받는 API입니다.") + @PostMapping("/result") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "패션 아이템 추천 성공" + //content = + ) + }) + public BaseResponse recommendFashionItem( + @Valid @RequestBody FashionItemRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + ItemRecommendResponse response = itemCommandService.recommendItem( + request, customUserDetails.getMember()); + return BaseResponse.onSuccess(response); + } + + @Operation(summary = "패션 아이템 추천 결과 조회 API", description = "사용자 본인의 패션 아이템 추천 결과를 조회합니다.") + @GetMapping("/result") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "패션 아이템 추천 결과 조회 성공" + ) + }) + public BaseResponse> getRecommendedFashionItem( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "cursor", required = false) Long cursor, + @RequestParam(name = "size", defaultValue = "15") Integer size + + ) { + CursorResult response = itemQueryService.getRecommendedItems(customUserDetails.getMember(), cursor, size); + return BaseResponse.onSuccess(response); + } +} diff --git a/src/main/java/com/example/mody/domain/fashionItem/dto/response/FashionItemRecommendResponse.java b/src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemGptResponse.java similarity index 82% rename from src/main/java/com/example/mody/domain/fashionItem/dto/response/FashionItemRecommendResponse.java rename to src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemGptResponse.java index 37e21c5..5aabdd7 100644 --- a/src/main/java/com/example/mody/domain/fashionItem/dto/response/FashionItemRecommendResponse.java +++ b/src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemGptResponse.java @@ -3,18 +3,12 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; -@Schema(description = "아이템 추천 정보") +@Schema(description = "패션 아이템 추천 Gpt 응답") @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor -public class FashionItemRecommendResponse { - -// @Schema( -// description = "회원 닉네임", -// example = "영희" -// ) -// private String name; +public class ItemGptResponse { @Schema( description = "패션 아이템 이름", diff --git a/src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemRecommendResponse.java b/src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemRecommendResponse.java new file mode 100644 index 0000000..f55d8f0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemRecommendResponse.java @@ -0,0 +1,20 @@ +package com.example.mody.domain.fashionItem.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(description = "패션 아이템 추천 API 응답") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ItemRecommendResponse { + + @Schema( + description = "회원 닉네임", + example = "영희" + ) + private String nickName; + + private ItemGptResponse itemGptResponse; +} diff --git a/src/main/java/com/example/mody/domain/fashionItem/dto/response/FashionItemsResponse.java b/src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemsResponse.java similarity index 72% rename from src/main/java/com/example/mody/domain/fashionItem/dto/response/FashionItemsResponse.java rename to src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemsResponse.java index a3c08ef..d291637 100644 --- a/src/main/java/com/example/mody/domain/fashionItem/dto/response/FashionItemsResponse.java +++ b/src/main/java/com/example/mody/domain/fashionItem/dto/response/ItemsResponse.java @@ -1,6 +1,7 @@ package com.example.mody.domain.fashionItem.dto.response; import com.example.mody.domain.fashionItem.entity.FashionItem; +import com.example.mody.global.dto.response.CursorPagination; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @@ -12,7 +13,7 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor -public class FashionItemsResponse { +public class ItemsResponse { @Schema( description = "회원 닉네임", @@ -21,23 +22,15 @@ public class FashionItemsResponse { private String nickName; @Schema(description = "패션 아이템 추천 리스트") - private List fashionItemRecommendations; - - //단일 추천 결과 생성 메서드 - public static FashionItemsResponse of(String nickName, FashionItemRecommendation fashionItemRecommendation) { - return FashionItemsResponse.builder() - .nickName(nickName) - .fashionItemRecommendations(List.of(fashionItemRecommendation)) - .build(); - } + private List fashionItemRecommendations; //여러 추천 결과 생성 메서드 - public static FashionItemsResponse of(String nickName, List fashionItems) { - List recommendations = fashionItems.stream() - .map(FashionItemRecommendation::from) + public static ItemsResponse of(String nickName, List fashionItems) { + List recommendations = fashionItems.stream() + .map(ItemRecommendation::from) .collect(Collectors.toList()); - return FashionItemsResponse.builder() + return ItemsResponse.builder() .nickName(nickName) .fashionItemRecommendations(recommendations) .build(); @@ -48,7 +41,7 @@ public static FashionItemsResponse of(String nickName, List fashion @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor - public static class FashionItemRecommendation { + public static class ItemRecommendation { @Schema( description = "패션 아이템 아이디", @@ -80,8 +73,9 @@ public static class FashionItemRecommendation { ) private boolean isLiked; - public static FashionItemRecommendation from(FashionItem fashionItem) { - return FashionItemRecommendation.builder() + public static ItemRecommendation from(FashionItem fashionItem) { + return ItemRecommendation.builder() + .id(fashionItem.getId()) .item(fashionItem.getItem()) .description(fashionItem.getDescription()) .imageUrl(fashionItem.getImageUrl()) @@ -89,5 +83,4 @@ public static FashionItemRecommendation from(FashionItem fashionItem) { .build(); } } - } diff --git a/src/main/java/com/example/mody/domain/fashionItem/repository/FashionItemRepository.java b/src/main/java/com/example/mody/domain/fashionItem/repository/FashionItemRepository.java deleted file mode 100644 index 9902492..0000000 --- a/src/main/java/com/example/mody/domain/fashionItem/repository/FashionItemRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.mody.domain.fashionItem.repository; - -import com.example.mody.domain.fashionItem.entity.FashionItem; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface FashionItemRepository extends JpaRepository { -} diff --git a/src/main/java/com/example/mody/domain/fashionItem/repository/ItemRepository.java b/src/main/java/com/example/mody/domain/fashionItem/repository/ItemRepository.java new file mode 100644 index 0000000..e3faafb --- /dev/null +++ b/src/main/java/com/example/mody/domain/fashionItem/repository/ItemRepository.java @@ -0,0 +1,22 @@ +package com.example.mody.domain.fashionItem.repository; + +import com.example.mody.domain.fashionItem.entity.FashionItem; +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 ItemRepository extends JpaRepository { + + @Query(""" + SELECT fi FROM FashionItem fi + WHERE fi.member.id = :memberId + AND (:cursor IS NULL OR fi.id <= :cursor) + ORDER BY fi.id DESC + LIMIT :size + """) + List findRecommendedItems(@Param("memberId") Long memberId, + @Param("cursor") Long cursor, + @Param("size") int size); +} diff --git a/src/main/java/com/example/mody/domain/fashionItem/service/FashionItemCommandService.java b/src/main/java/com/example/mody/domain/fashionItem/service/FashionItemCommandService.java deleted file mode 100644 index 390f209..0000000 --- a/src/main/java/com/example/mody/domain/fashionItem/service/FashionItemCommandService.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mody.domain.fashionItem.service; - -import com.example.mody.domain.fashionItem.dto.request.FashionItemRequest; -import com.example.mody.domain.fashionItem.dto.response.FashionItemRecommendResponse; -import com.example.mody.domain.member.entity.Member; -import com.example.mody.domain.style.dto.BodyTypeDTO; - -public interface FashionItemCommandService { - - public FashionItemRecommendResponse recommendItem(FashionItemRequest request, Member member); -} diff --git a/src/main/java/com/example/mody/domain/fashionItem/service/ItemCommandService.java b/src/main/java/com/example/mody/domain/fashionItem/service/ItemCommandService.java new file mode 100644 index 0000000..6608382 --- /dev/null +++ b/src/main/java/com/example/mody/domain/fashionItem/service/ItemCommandService.java @@ -0,0 +1,11 @@ +package com.example.mody.domain.fashionItem.service; + +import com.example.mody.domain.fashionItem.dto.request.FashionItemRequest; +import com.example.mody.domain.fashionItem.dto.response.ItemRecommendResponse; +import com.example.mody.domain.fashionItem.dto.response.ItemsResponse; +import com.example.mody.domain.member.entity.Member; + +public interface ItemCommandService { + + public ItemRecommendResponse recommendItem(FashionItemRequest request, Member member); +} diff --git a/src/main/java/com/example/mody/domain/fashionItem/service/FashionItemCommandServiceImpl.java b/src/main/java/com/example/mody/domain/fashionItem/service/ItemCommandServiceImpl.java similarity index 69% rename from src/main/java/com/example/mody/domain/fashionItem/service/FashionItemCommandServiceImpl.java rename to src/main/java/com/example/mody/domain/fashionItem/service/ItemCommandServiceImpl.java index 981be10..fa1fdb8 100644 --- a/src/main/java/com/example/mody/domain/fashionItem/service/FashionItemCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/fashionItem/service/ItemCommandServiceImpl.java @@ -5,9 +5,11 @@ import com.example.mody.domain.chatgpt.service.ChatGptService; import com.example.mody.domain.exception.BodyTypeException; import com.example.mody.domain.fashionItem.dto.request.FashionItemRequest; -import com.example.mody.domain.fashionItem.dto.response.FashionItemRecommendResponse; +import com.example.mody.domain.fashionItem.dto.response.ItemGptResponse; +import com.example.mody.domain.fashionItem.dto.response.ItemRecommendResponse; +import com.example.mody.domain.fashionItem.dto.response.ItemsResponse; import com.example.mody.domain.fashionItem.entity.FashionItem; -import com.example.mody.domain.fashionItem.repository.FashionItemRepository; +import com.example.mody.domain.fashionItem.repository.ItemRepository; import com.example.mody.domain.member.entity.Member; import com.example.mody.domain.style.dto.BodyTypeDTO; import com.example.mody.global.common.exception.code.status.BodyTypeErrorStatus; @@ -20,15 +22,15 @@ @Service @Transactional @RequiredArgsConstructor -public class FashionItemCommandServiceImpl implements FashionItemCommandService{ +public class ItemCommandServiceImpl implements ItemCommandService { private final MemberBodyTypeRepository memberBodyTypeRepository; private final ObjectMapper objectMapper; private final ChatGptService chatGptService; - private final FashionItemRepository fashionItemRepository; + private final ItemRepository itemRepository; @Override - public FashionItemRecommendResponse recommendItem(FashionItemRequest request, Member member) { + public ItemRecommendResponse recommendItem(FashionItemRequest request, Member member) { //현재 유저의 bodyType 정보를 받아오기 MemberBodyType latestBodyType = memberBodyTypeRepository.findTopByMemberOrderByCreatedAt(member) @@ -43,16 +45,21 @@ public FashionItemRecommendResponse recommendItem(FashionItemRequest request, Me //bodyType 데이터를 String 형태로 변환 (gpt로 넘겨주기 위해서) String bodyType = convertBodyTypeToJson(bodyTypeDTO); - FashionItemRecommendResponse response = chatGptService.recommendGptItem(request, bodyType); + ItemGptResponse recommendation = chatGptService.recommendGptItem(request, bodyType); FashionItem fashionItem = FashionItem.builder() - .item(response.getItem()) - .description(response.getDescription()) - .imageUrl(response.getImageUrl()) + .item(recommendation.getItem()) + .description(recommendation.getDescription()) + .imageUrl(recommendation.getImageUrl()) .member(member) .build(); - fashionItemRepository.save(fashionItem); + itemRepository.save(fashionItem); + + ItemRecommendResponse response = ItemRecommendResponse.builder() + .nickName(member.getNickname()) + .itemGptResponse(recommendation) + .build(); return response; } diff --git a/src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryService.java b/src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryService.java new file mode 100644 index 0000000..804e116 --- /dev/null +++ b/src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryService.java @@ -0,0 +1,10 @@ +package com.example.mody.domain.fashionItem.service; + +import com.example.mody.domain.fashionItem.dto.response.ItemsResponse; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.global.dto.response.CursorResult; + +public interface ItemQueryService { + + public CursorResult getRecommendedItems(Member memer, Long cursor, int size); +} diff --git a/src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryServiceImpl.java b/src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryServiceImpl.java new file mode 100644 index 0000000..e948757 --- /dev/null +++ b/src/main/java/com/example/mody/domain/fashionItem/service/ItemQueryServiceImpl.java @@ -0,0 +1,45 @@ +package com.example.mody.domain.fashionItem.service; + +import com.example.mody.domain.fashionItem.dto.response.ItemsResponse; +import com.example.mody.domain.fashionItem.entity.FashionItem; +import com.example.mody.domain.fashionItem.repository.ItemRepository; +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.GlobalErrorStatus; +import com.example.mody.global.dto.response.CursorPagination; +import com.example.mody.global.dto.response.CursorResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ItemQueryServiceImpl implements ItemQueryService{ + + private final ItemRepository itemRepository; + + @Override + public CursorResult getRecommendedItems(Member member, Long cursor, int size) { + + if(size<=0){ + throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST); + } + + //추천 받은 아이템들 가져오기 + List fashionItems = itemRepository.findRecommendedItems(member.getId(), cursor, size + 1); + + boolean hasNext = fashionItems.size() > size; + + List resultList = hasNext ? fashionItems.subList(0, size) : fashionItems; + + //마지막 아이템 id 계산 + Long nextCursor = fashionItems.isEmpty() ? null : fashionItems.get(fashionItems.size() - 1).getId(); + + ItemsResponse response = ItemsResponse.of(member.getNickname(), resultList); + + return new CursorResult<>(response, new CursorPagination(hasNext, nextCursor)); + } +} 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 0000000..6ae5790 --- /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; +} From f03a646badb188d76414fdfb4a2554148964ed71 Mon Sep 17 00:00:00 2001 From: jher235 Date: Thu, 30 Jan 2025 18:24:28 +0900 Subject: [PATCH 189/281] =?UTF-8?q?:sparkles:=20[#88]=20feature:=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../style/entity/mapping/MemberStyleLike.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/style/entity/mapping/MemberStyleLike.java diff --git a/src/main/java/com/example/mody/domain/style/entity/mapping/MemberStyleLike.java b/src/main/java/com/example/mody/domain/style/entity/mapping/MemberStyleLike.java new file mode 100644 index 0000000..12bd18c --- /dev/null +++ b/src/main/java/com/example/mody/domain/style/entity/mapping/MemberStyleLike.java @@ -0,0 +1,32 @@ +package com.example.mody.domain.style.entity.mapping; + +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.style.entity.Style; +import com.example.mody.global.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member_style_like") +public class MemberStyleLike extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_style_like_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "style_id") + private Style style; + + public MemberStyleLike(Member member, Style style){ + this.member = member; + this.style = style; + } +} From 1e9e0d7386de5f77713a010dad79f2e220bb41e6 Mon Sep 17 00:00:00 2001 From: jher235 Date: Thu, 30 Jan 2025 18:25:51 +0900 Subject: [PATCH 190/281] =?UTF-8?q?:sparkles:=20[#88]=20feature:=20llm?= =?UTF-8?q?=EB=A1=9C=EB=B6=80=ED=84=B0=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=EB=B0=9B=EB=8A=94=20dto=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/StyleRecommendation.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendation.java diff --git a/src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendation.java b/src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendation.java new file mode 100644 index 0000000..87d1ba0 --- /dev/null +++ b/src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendation.java @@ -0,0 +1,57 @@ +package com.example.mody.domain.style.dto.response; + +import com.example.mody.domain.style.entity.Style; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(description = "스타일 추천 정보") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class StyleRecommendation { + + @Schema(description = "좋아요 여부") + public Boolean isLiked = false; + + @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://example.com/street-vintage-style.jpg" + ) + private String imageUrl; + + public static StyleRecommendation from(Style style) { + return StyleRecommendation.builder() + .recommendedStyle(style.getRecommendedStyle()) + .introduction(style.getIntroduction()) + .styleDirection(style.getStyleDirection()) + .practicalStylingTips(style.getPracticalStylingTips()) + .imageUrl(style.getImageUrl()) + .build(); + } + +} From 6d2c7e4bec345a4a98fc45d63f75e66b1ded5e9b Mon Sep 17 00:00:00 2001 From: jher235 Date: Thu, 30 Jan 2025 18:26:17 +0900 Subject: [PATCH 191/281] =?UTF-8?q?:sparkles:=20[#88]=20feature:=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=A0=20dto=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/StyleRecommendResponse.java | 96 +++++++++---------- .../dto/response/StyleRecommendResponses.java | 27 ++++++ 2 files changed, 72 insertions(+), 51 deletions(-) create mode 100644 src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendResponses.java diff --git a/src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendResponse.java b/src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendResponse.java index a9f2d35..45a4fea 100644 --- a/src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendResponse.java +++ b/src/main/java/com/example/mody/domain/style/dto/response/StyleRecommendResponse.java @@ -1,5 +1,6 @@ package com.example.mody.domain.style.dto.response; +import com.example.mody.domain.member.entity.Member; import com.example.mody.domain.style.entity.Style; import io.swagger.v3.oas.annotations.media.Schema; @@ -9,9 +10,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.List; -import java.util.stream.Collectors; - @Schema(description = "스타일 추천 응답 DTO") @Getter @Builder @@ -19,82 +17,78 @@ @AllArgsConstructor public class StyleRecommendResponse { + @Schema(description = "스타일 아이디") + private Long styleId; + @Schema( - description = "회원 닉네임", - example = "영희" + description = "회원 아이디", + example = "1" ) - private String nickName; + private Long memberId; @Schema( - description = "추천 스타일 리스트" + description = "회원 닉네임", + example = "영희" ) - private List styleRecommendations; - - //단일 추천 결과 생성 메서드 - public static StyleRecommendResponse of(String nickName, StyleRecommendation styleRecommendation) { - return StyleRecommendResponse.builder() - .nickName(nickName) - .styleRecommendations(List.of(styleRecommendation)) - .build(); - } + private String nickname; - //여러 추천 결과 생성 메서드 - public static StyleRecommendResponse of(String nickName, List