From f3dbb46b119ab942953e95fad15fd75fc141c9f1 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 14 Jul 2025 17:59:01 +0900 Subject: [PATCH 01/60] =?UTF-8?q?feat:=20GlobalException=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 --- .../gitdeun/common/exception/ErrorCode.java | 42 +++++++++++++++++++ .../common/exception/ErrorResponse.java | 24 +++++++++++ .../common/exception/GlobalException.java | 10 +++++ .../exception/GlobalExceptionHandler.java | 13 ++++++ 4 files changed, 89 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorResponse.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalException.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java new file mode 100644 index 0000000..fbdde60 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + // 인증 관련 + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "AUTH-001", "비밀번호가 일치하지 않습니다."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-002", "리프레시 토큰이 만료되었습니다."), + NO_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-003", "토큰이 존재하지 않습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-004", "유효하지 않은 토큰입니다."), + ACCESS_DENIED(HttpStatus.UNAUTHORIZED, "AUTH-005", "인증되지 않은 유저입니다."), + DELETE_USER_DENIED(HttpStatus.FORBIDDEN, "AUTH-006", "회원 탈퇴가 거부되었습니다."), + ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-007", "권한 정보가 없습니다."), + + + // 계정 관련 + DUPLICATED_REAL_ID(HttpStatus.CONFLICT, "ACCOUNT-001", "이미 존재하는 아이디입니다."), + USER_NOT_FOUND_BY_REAL_ID(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 아이디의 회원을 찾을 수 없습니다."), + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "AUTH-011", "잘못된 접근입니다."), + + + // S3 파일 관련 + FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-001", "업로드 가능한 파일 개수를 초과했습니다."), + FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-002", "파일 크기가 허용된 용량을 초과했습니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-003", "지원하지 않는 파일 형식입니다."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-004", "요청한 파일을 찾을 수 없습니다."), + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-006", "파일 업로드 중 오류가 발생했습니다."), + INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-006", "파일 목록이 비어있거나 유효하지 않습니다."), + INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-007", "파일 경로나 이름이 유효하지 않습니다."), + FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-008", "파일 이동 중 오류가 발생했습니다."); + + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorResponse.java new file mode 100644 index 0000000..678e896 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorResponse.java @@ -0,0 +1,24 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.http.ResponseEntity; + +@ToString +@Getter +@Builder +public class ErrorResponse { + private final String code; + private final String message; + + public static ResponseEntity toResponseEntity(ErrorCode e) { + return ResponseEntity + .status(e.getHttpStatus()) + .body(ErrorResponse.builder() + .code(e.getCode()) + .message(e.getMessage()) + .build()); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalException.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalException.java new file mode 100644 index 0000000..10d2404 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalException.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GlobalException extends RuntimeException { + ErrorCode errorCode; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalExceptionHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e1ffd1e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.common.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(GlobalException.class) + public ResponseEntity handleGlobalException(GlobalException e) { + return ErrorResponse.toResponseEntity(e.getErrorCode()); + } +} From 7298b39352ad50c079802a2ce25c2a63ec959839 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 14 Jul 2025 19:03:01 +0900 Subject: [PATCH 02/60] =?UTF-8?q?.gitignore=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 72265e9..abdb730 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ out/ *.jar +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ *.war *.ear From 5c386b1217d361fb2cbf8e772a97e5ba223535fd Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 14 Jul 2025 19:04:26 +0900 Subject: [PATCH 03/60] Initial Commit --- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 Date: Mon, 14 Jul 2025 20:14:29 +0900 Subject: [PATCH 04/60] Initial Commit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index b71c79d..0f66f0e 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ repositories { } ext { - mapstructVersion = "1.6.0.Final" + mapstructVersion = "1.6.3" springDocVersion = "2.5.0" jjwtVersion = "0.12.6" awsSdkVersion = "2.25.68" From abbba3c5a5cb43266f88860fd39089d0aefbf146 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 14 Jul 2025 22:02:02 +0900 Subject: [PATCH 05/60] =?UTF-8?q?feat:=20GlobalLoggingAspect=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EB=A1=9C=EA=B9=85=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/aop/GlobalLoggingAspect.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java b/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java new file mode 100644 index 0000000..d6cebdb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.common.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.*; +import org.springframework.stereotype.Component; + +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +public class GlobalLoggingAspect { + + @Pointcut("execution(* com.teamEWSN.gitdeun..*(..))") + private void globalPointcut() { + + } + + @Before("globalPointcut()") + public void logBeforeMethod(JoinPoint joinPoint) { + String methodName = joinPoint.getSignature().toShortString(); + Object[] args = joinPoint.getArgs(); + + log.debug("[실행 메서드]: {} [매개변수]: {}", methodName, Arrays.toString(args)); + } + + @AfterReturning(value = "globalPointcut()", returning = "result") + public void logAfterMethod(JoinPoint joinPoint, Object result) { + String methodName = joinPoint.getSignature().toShortString(); + + log.debug("[종료 메서드]: {} [반환값]: {}", methodName, result); + } + + @AfterThrowing(value = "globalPointcut()", throwing = "ex") + public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) { + String methodName = joinPoint.getSignature().toShortString(); + + log.error("[예외 발생 메서드]: {} [예외]: {}", methodName, ex.getMessage()); + } +} \ No newline at end of file From 9e9268b50bba0f8151aeb582c6f9d6a1f621ed20 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Tue, 15 Jul 2025 03:35:10 +0900 Subject: [PATCH 06/60] =?UTF-8?q?feat:=20BaseEntity=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createdAt - updatedAt --- .../gitdeun/common/util/BaseEntity.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java new file mode 100644 index 0000000..e2dcb5b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java @@ -0,0 +1,28 @@ +package com.teamEWSN.gitdeun.common.util; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@MappedSuperclass +public class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime updatedAt; + +} \ No newline at end of file From 65d9a99e970333852beba2e80f129b024c78c39f Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Tue, 15 Jul 2025 04:02:08 +0900 Subject: [PATCH 07/60] =?UTF-8?q?feat:=20S3=20Template=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S3Controller: 파일 업로드, 삭제, 다운로드 구현 - S3Service 구현 - ErrorCode S3 개선 - application-s3Bucket.yml 개선 --- .../gitdeun/common/exception/ErrorCode.java | 22 +-- .../s3/controller/S3BucketController.java | 56 ++++++++ .../common/s3/service/S3BucketService.java | 127 ++++++++++++++++++ src/main/resources/application-s3Bucket.yml | 21 +-- 4 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index fbdde60..713dae7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -24,14 +24,20 @@ public enum ErrorCode { // S3 파일 관련 - FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-001", "업로드 가능한 파일 개수를 초과했습니다."), - FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-002", "파일 크기가 허용된 용량을 초과했습니다."), - INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-003", "지원하지 않는 파일 형식입니다."), - FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-004", "요청한 파일을 찾을 수 없습니다."), - FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-006", "파일 업로드 중 오류가 발생했습니다."), - INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-006", "파일 목록이 비어있거나 유효하지 않습니다."), - INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-007", "파일 경로나 이름이 유효하지 않습니다."), - FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-008", "파일 이동 중 오류가 발생했습니다."); + // Client Errors (4xx) + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), + INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-002", "파일 목록이 비어있거나 유효하지 않습니다."), + INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-003", "파일 경로나 이름이 유효하지 않습니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-004", "지원하지 않는 파일 형식입니다."), + FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-005", "업로드 가능한 파일 개수를 초과했습니다."), + FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-006", "파일 크기가 허용된 용량을 초과했습니다."), + INVALID_S3_URL(HttpStatus.BAD_REQUEST, "FILE-007", "S3 URL 형식이 올바르지 않습니다."), + + // Server Errors (5xx) + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-501", "파일 업로드 중 서버 오류가 발생했습니다."), + FILE_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-502", "파일 다운로드 중 서버 오류가 발생했습니다."), + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-503", "파일 삭제 중 서버 오류가 발생했습니다."), + FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-504", "파일 이동 중 서버 오류가 발생했습니다."); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java new file mode 100644 index 0000000..de56ba0 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java @@ -0,0 +1,56 @@ +package com.teamEWSN.gitdeun.common.s3.controller; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/s3/bucket") +@RequiredArgsConstructor +public class S3BucketController { + + private final S3BucketService s3BucketService; + private static final int MAX_FILE_COUNT = 10; + + @PostMapping("/upload") + public ResponseEntity> uploadFiles( + @RequestParam("files") List files, + @RequestParam("path") String path + ) { + // FILE-005: 업로드 가능한 파일 개수를 초과했습니다. + if (files.size() > MAX_FILE_COUNT) { + throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); + } + + List fileUrls = s3BucketService.upload(files, path); + return ResponseEntity.ok(fileUrls); + } + + @DeleteMapping("/delete") + public ResponseEntity deleteFiles(@RequestBody List urls) { + s3BucketService.remove(urls); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/download") + public ResponseEntity downloadFile(@RequestParam("url") String url) { + Resource resource = s3BucketService.download(url); + String filename = URLEncoder.encode(resource.getFilename(), StandardCharsets.UTF_8); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(resource); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java new file mode 100644 index 0000000..6ed3ec5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java @@ -0,0 +1,127 @@ +package com.teamEWSN.gitdeun.common.s3.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.s3.S3Template; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class S3BucketService { + + private final S3Template s3Template; + + @Value("${cloud.aws.s3.bucket.name}") + private String bucketName; + + public List upload(List files, String path) { + // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + if (files == null || files.stream().allMatch(MultipartFile::isEmpty)) { + throw new GlobalException(ErrorCode.INVALID_FILE_LIST); + } + + List uploadedUrls = new ArrayList<>(); + for (MultipartFile file : files) { + if (file.isEmpty()) continue; + + if (!isValidFileType(file.getOriginalFilename())) { + // FILE-004: 지원하지 않는 파일 형식입니다. + throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); + } + + String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename()); + + try { + S3Resource s3Resource = s3Template.upload(bucketName, fullPath, file.getInputStream()); + uploadedUrls.add(s3Resource.getURL().toString()); + } catch (IOException | SdkException e) { + // FILE-501: 파일 업로드 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + return uploadedUrls; + } + + public void remove(List urls) { + // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + if (urls == null || urls.isEmpty()) { + throw new GlobalException(ErrorCode.INVALID_FILE_LIST); + } + + for (String url : urls) { + String key = extractKeyFromUrl(url); + + try { + if (!s3Template.objectExists(bucketName, key)) { + // FILE-001: 요청한 파일을 찾을 수 없습니다. + throw new GlobalException(ErrorCode.FILE_NOT_FOUND); + } + s3Template.deleteObject(bucketName, key); + } catch (SdkException e) { + // FILE-503: 파일 삭제 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_DELETE_FAILED); + } + } + } + + public S3Resource download(String url) { + String key = extractKeyFromUrl(url); + + try { + if (!s3Template.objectExists(bucketName, key)) { + // FILE-001: 요청한 파일을 찾을 수 없습니다. + throw new GlobalException(ErrorCode.FILE_NOT_FOUND); + } + return s3Template.download(bucketName, key); + } catch (SdkException e) { + // FILE-502: 파일 다운로드 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_DOWNLOAD_FAILED); + } + } + + private String extractKeyFromUrl(String url) { + try { + String urlPrefix = "https://" + bucketName + ".s3."; + int startIndex = url.indexOf(urlPrefix); + int keyStartIndex = url.indexOf('/', startIndex + urlPrefix.length()); + return url.substring(keyStartIndex + 1); + } catch (Exception e) { + // FILE-007: S3 URL 형식이 올바르지 않습니다. + throw new GlobalException(ErrorCode.INVALID_S3_URL); + } + } + + private String generateValidPath(String path) { + if (path == null || path.trim().isEmpty()) { + return ""; + } + if (path.contains("..")) { + // FILE-003: 파일 경로나 이름이 유효하지 않습니다. + throw new GlobalException(ErrorCode.INVALID_FILE_PATH); + } + return path.replaceAll("^/+|/+$", "") + "/"; + } + + private String createUniqueFileName(String originalFileName) { + String extension = StringUtils.getFilenameExtension(originalFileName); + return UUID.randomUUID() + "." + extension; + } + + private boolean isValidFileType(String filename) { + if (filename == null) return false; + String extension = StringUtils.getFilenameExtension(filename.toLowerCase()); + List allowedExtensions = List.of("jpg", "jpeg", "png", "gif", "pdf", "docs"); // 허용 확장자 + return allowedExtensions.contains(extension); + } +} \ No newline at end of file diff --git a/src/main/resources/application-s3Bucket.yml b/src/main/resources/application-s3Bucket.yml index 74b910e..86a1525 100644 --- a/src/main/resources/application-s3Bucket.yml +++ b/src/main/resources/application-s3Bucket.yml @@ -1,12 +1,15 @@ -aws: - s3: - bucket: - stack.auto: false - name: gitdeun - region: ap-northeast-2 - credentials: - accessKey: ${S3_ACCESS_KEY} - secretAccessKey: ${S3_SECRET_KEY} +cloud: + aws: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ap-northeast-2 + stack: + auto: false # CloudFormation 스택 자동 생성을 비활성화 + s3: + bucket: + name: gitdeun spring: config: From 5c3d831f1cc24bfe6acc8364984b10645f976dff Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 16 Jul 2025 15:17:50 +0900 Subject: [PATCH 08/60] =?UTF-8?q?feat:=20RedisConfig=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=B0=8F=20=EC=9D=B8=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 + .../gitdeun/common/config/CacheConfig.java | 35 +++++++++++++ .../common/config/RestTemplateConfig.java | 14 +++++ .../common/config/redis/RedisConfig.java | 49 +++++++++++++++++ .../common/cookie/CookieProperties.java | 15 ++++++ .../gitdeun/common/cookie/CookieUtil.java | 52 +++++++++++++++++++ .../gitdeun/common/util/CacheType.java | 14 +++++ 7 files changed, 180 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java diff --git a/docker-compose.yml b/docker-compose.yml index 9ac43df..437b27f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ volumes: - redis-data:/data # 데이터 지속성을 위한 볼륨 추가 command: redis-server --appendonly yes + restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java new file mode 100644 index 0000000..6b19cc8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java @@ -0,0 +1,35 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.teamEWSN.gitdeun.common.util.CacheType; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + + // 각 캐시 타입에 대한 설정 등록 + Arrays.stream(CacheType.values()) + .forEach(cacheType -> { + cacheManager.registerCustomCache(cacheType.getCacheName(), + Caffeine.newBuilder() + .recordStats() // 캐시 통계 기록 + .expireAfterWrite(Duration.ofHours(cacheType.getExpiredAfterWrite())) // 항목 만료 시간 + .maximumSize(cacheType.getMaximumSize()) // 최대 크기 + .build() + ); + }); + + return cacheManager; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..ef3bd56 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java new file mode 100644 index 0000000..1fed0d4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java @@ -0,0 +1,49 @@ +package com.teamEWSN.gitdeun.common.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(redisHost); + redisConfig.setPort(redisPort); + + if (!redisPassword.isEmpty()) { + redisConfig.setPassword(RedisPassword.of(redisPassword)); + } + + return new LettuceConnectionFactory(redisConfig); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // Redis에 저장되는 데이터의 직렬화 방식을 지정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return redisTemplate; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java new file mode 100644 index 0000000..53eff89 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.common.cookie; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.cookie") +@Getter +@Setter +public class CookieProperties { + private boolean secure; + private String sameSite; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java new file mode 100644 index 0000000..09490ce --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +import org.springframework.web.util.WebUtils; + +import java.util.Optional; + +@Component +public class CookieUtil { + + private final CookieProperties cookieProperties; + + public CookieUtil(CookieProperties cookieProperties) { + this.cookieProperties = cookieProperties; + } + // 쿠키 설정 + public void setCookie(HttpServletResponse response, String name, String value, Long maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(cookieProperties.isSecure()) + .path("/") + .sameSite(cookieProperties.getSameSite()) + .maxAge(maxAge) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 쿠키 삭제 + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, null) + .httpOnly(true) + .secure(cookieProperties.isSecure()) + .path("/") + .sameSite(cookieProperties.getSameSite()) + .maxAge(0) // 즉시 만료 + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 특정 쿠키 값 가져오기 + public Optional getCookieValue(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + return cookie != null ? Optional.of(cookie.getValue()) : Optional.empty(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java new file mode 100644 index 0000000..3530db8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.util; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200); // 자동완성 캐시 + + private final String cacheName; + private final int expiredAfterWrite; // 시간(hour) 단위 + private final int maximumSize; // 최대 캐시 항목 수 +} \ No newline at end of file From ccbf0a2f38fdefb049ff94e8420f701145273637 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 16 Jul 2025 15:19:00 +0900 Subject: [PATCH 09/60] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B8=B0=EB=B3=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=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 --- .../gitdeun/common/config/SecurityPath.java | 27 +++++++ .../user/controller/AuthController.java | 62 +++++++++++++++ .../user/dto/UserTokenResponseDto.java | 10 +++ .../teamEWSN/gitdeun/user/entity/Role.java | 13 ++++ .../teamEWSN/gitdeun/user/entity/User.java | 63 ++++++++++++++- .../user/repository/UserRepository.java | 7 +- .../gitdeun/user/service/AuthService.java | 77 +++++++++++++++++++ 7 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java new file mode 100644 index 0000000..aa72761 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -0,0 +1,27 @@ +package com.teamEWSN.gitdeun.common.config; + + +public class SecurityPath { + + // permitAll + public static final String[] PUBLIC_ENDPOINTS = { + "/api/signup", + "/api/login", + "/api/token/refresh", + "/api/users/check-duplicate", + "/" + }; + + // hasRole("USER") + public static final String[] USER_ENDPOINTS = { + "/api/users/me", + "/api/users/me/**", + "/api/logout" + }; + + // hasRole("ADMIN") + public static final String[] ADMIN_ENDPOINTS = { + "/api/admin/**" + }; +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java new file mode 100644 index 0000000..2addc32 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -0,0 +1,62 @@ +package com.teamEWSN.gitdeun.user.controller; + +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.user.dto.UserTokenResponseDto; +import com.teamEWSN.gitdeun.user.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/oauth") +@RequiredArgsConstructor +public class AuthController { + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + private final AuthService authService; + private final CookieUtil cookieUtil; + + + // 로그아웃 API + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + authService.logout(request, response); + return ResponseEntity.noContent().build(); + } + + // 토큰 재발급 + @PostMapping("/token/refresh") + public ResponseEntity refreshAccessToken( + @CookieValue(name = "refreshToken") String refreshToken, + HttpServletResponse response) { + JwtToken newJwtToken = authService.refreshTokens(refreshToken); + + cookieUtil.setCookie(response, "refreshToken", newJwtToken.getRefreshToken(), authService.getRefreshTokenExpiredSeconds()); + return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); + } + + /** + * 이미 로그인된 사용자의 계정에 추가로 소셜 계정을 연동 + * @param userDetails 현재 로그인된 사용자 정보 (JWT 기반) + * @param provider 연동할 소셜 플랫폼 + * @param code 연동할 계정의 인가 코드 + */ + @GetMapping("/connect/{provider}") + public ResponseEntity connectSocialAccount( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable("provider") String provider, + @RequestParam String code) { + + authService.connectSocialAccount(userDetails.getId(), provider, code); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java new file mode 100644 index 0000000..97f900b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class UserTokenResponseDto { + private String accessToken; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java new file mode 100644 index 0000000..be3d139 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.user.entity; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + ROLE_USER, + ROLE_ADMIN; + + @Override + public String getAuthority() { + return name(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 2025a52..5e33ea3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -1,5 +1,64 @@ package com.teamEWSN.gitdeun.user.entity; -public class User { - +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.util.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +public class User extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 100) + private String name; + + @Column(nullable = false, length = 100, unique = true) + private String nickname; + + @Column(nullable = false, length = 256) + private String email; + + @Column(name="profile_image", length = 512) + private String profileImage; // image url + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List socialConnections = new ArrayList<>(); + + @Column(name = "deleted_at", columnDefinition = "DATETIME(0) DEFAULT CURRENT_TIMESTAMP") + private LocalDateTime deletedAt; + + + @Builder + public User(String name, String nickname, String email, String profileImage, Role role) { + this.name = name; + this.nickname = nickname; + this.email = email; + this.profileImage = profileImage; + this.role = role; + } + + // 회원 탈퇴 처리 + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + // 회원 닉네임 변경 + public void updateNickname(String newNickname) { + this.nickname = newNickname; + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index cdeeab9..8a8f3ac 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -1,5 +1,10 @@ package com.teamEWSN.gitdeun.user.repository; -public class UserRepository { +import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java new file mode 100644 index 0000000..1e9b516 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -0,0 +1,77 @@ +package com.teamEWSN.gitdeun.user.service; + + +import com.nimbusds.oauth2.sdk.token.RefreshToken; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final CookieUtil cookieUtil; + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + @Transactional + public void logout(HttpServletRequest request, HttpServletResponse response) { + String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION).replace("Bearer ", ""); + + // 1. Redis에서 Refresh Token 삭제 + refreshTokenRepository.findByAccessToken(accessToken) + .ifPresent(refreshTokenRepository::delete); + + // 2. 쿠키에서 Refresh Token 삭제 + cookieUtil.deleteCookie(response, "refreshToken"); + } + + @Transactional + public JwtToken refreshTokens(String oldRefreshToken) { + // 1. Redis에서 Refresh Token 조회 및 유효성 검증 + RefreshToken refreshToken = refreshTokenRepository.findById(oldRefreshToken) + .orElseThrow(() -> new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN)); + + // 2. 새로운 토큰 생성 + JwtToken newJwtToken = jwtTokenProvider.generateToken(refreshToken.getUserId(), refreshToken.getRole()); + + // 3. 기존 Refresh Token을 삭제하고 새로 발급된 토큰 정보로 저장 + refreshTokenRepository.delete(refreshToken); + refreshTokenRepository.save(new RefreshToken(newJwtToken.getRefreshToken(), refreshToken.getUserId(), refreshToken.getRole(), newJwtToken.getAccessToken())); + + return newJwtToken; + } + + // connectSocialAccount 로직은 CustomOAuth2UserService의 로직과 유사하게 + // 인가 코드로 토큰을 받고, 토큰으로 유저 정보를 받아와 SocialConnection을 생성/저장해야 합니다. + // 이 부분은 각 소셜 플랫폼의 API를 직접 호출해야 하므로 WebClient를 사용한 구현이 필요합니다. + @Transactional + public void connectSocialAccount(Long userId, String provider, String code) { + // TODO: WebClient를 사용하여 provider(github)의 + // 1. 인가 코드(code)로 Access Token 요청 + // 2. Access Token으로 사용자 정보 요청 + // 3. 받아온 정보로 SocialConnection 객체 생성 및 저장 + // 이 로직은 CustomOAuth2UserService의 로직을 참고하여 작성할 수 있습니다. + // WebClient 설정은 SecurityConfig에 Bean으로 등록 후 주입받아 사용합니다. + + System.out.printf("계정 연동 시도: userId=%d, provider=%s, code=%s\n", userId, provider, code); + } + + +} From 9636027736c405432a5238be0c3bf85286dd875f Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 16 Jul 2025 15:20:41 +0900 Subject: [PATCH 10/60] =?UTF-8?q?feat:=20OAuth2=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20webClient=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?,=20oauth=20=ED=86=A0=ED=81=B0=20=EC=95=94=ED=98=B8=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=B5=ED=98=B8=ED=99=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../common/config/WebClientConfig.java | 14 ++ .../common/converter/CryptoConverter.java | 59 ++++++++ .../gitdeun/common/exception/ErrorCode.java | 29 ++-- .../common/exception/OAuthException.java | 10 ++ .../common/oauth/dto/OAuth2UserDto.java | 17 +++ .../oauth/dto/provider/GitHubResponseDto.java | 37 +++++ .../oauth/dto/provider/GoogleResponseDto.java | 36 +++++ .../oauth/dto/provider/OAuth2ResponseDto.java | 18 +++ .../common/oauth/entity/CustomOAuth2User.java | 41 ++++++ .../common/oauth/entity/OauthProvider.java | 6 + .../common/oauth/entity/SocialConnection.java | 52 +++++++ .../handler/CustomOAuth2FailureHandler.java | 43 ++++++ .../handler/CustomOAuth2SuccessHandler.java | 46 ++++++ .../SocialConnectionRepository.java | 9 ++ .../service/CustomOAuth2UserService.java | 134 ++++++++++++++++++ .../common/oauth/service/GitHubApiHelper.java | 43 ++++++ .../common/oauth/service/GoogleApiHelper.java | 21 +++ src/main/resources/application-dev.yml | 9 ++ src/main/resources/application-prod.yml | 9 ++ src/main/resources/application.yml | 22 ++- 21 files changed, 643 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java diff --git a/build.gradle b/build.gradle index 0f66f0e..0c7b620 100644 --- a/build.gradle +++ b/build.gradle @@ -90,6 +90,9 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Reactive 웹 프레임워크 WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java new file mode 100644 index 0000000..f9e78f8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java b/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java new file mode 100644 index 0000000..b928d0b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java @@ -0,0 +1,59 @@ +package com.teamEWSN.gitdeun.common.converter; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.util.Base64; + +@Converter +@Component +public class CryptoConverter implements AttributeConverter { + + private static final String ALGORITHM = "AES"; + private static String staticSecretKey; + private Key key; + + @Value("${db.crypto-key}") + public void setStaticSecretKey(String secretKey) { + CryptoConverter.staticSecretKey = secretKey; + } + + @PostConstruct + public void init() { + if (staticSecretKey == null || staticSecretKey.length() < 16) { + throw new IllegalArgumentException("암호화 키는 16자 이상이어야 합니다."); + } + key = new SecretKeySpec(staticSecretKey.substring(0, 16).getBytes(), ALGORITHM); + } + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) return null; + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, key); + return Base64.getEncoder().encodeToString(cipher.doFinal(attribute.getBytes())); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt attribute", e); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, key); + return new String(cipher.doFinal(Base64.getDecoder().decode(dbData))); + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt attribute", e); + } + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 713dae7..9eb1a18 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -8,20 +8,27 @@ @AllArgsConstructor public enum ErrorCode { // 인증 관련 - INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "AUTH-001", "비밀번호가 일치하지 않습니다."), - REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-002", "리프레시 토큰이 만료되었습니다."), - NO_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-003", "토큰이 존재하지 않습니다."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-004", "유효하지 않은 토큰입니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-001", "유효하지 않은 액세스 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-002", "리프레시 토큰이 유효하지 않습니다."), + TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-003", "요청에 토큰이 존재하지 않습니다."), + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "AUTH-004", "접근 권한이 없습니다."), ACCESS_DENIED(HttpStatus.UNAUTHORIZED, "AUTH-005", "인증되지 않은 유저입니다."), - DELETE_USER_DENIED(HttpStatus.FORBIDDEN, "AUTH-006", "회원 탈퇴가 거부되었습니다."), - ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-007", "권한 정보가 없습니다."), - + INVALID_SECRET_KEY(HttpStatus.UNAUTHORIZED, "AUTH-006", "유효하지 않은 비밀 키입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH-007", "유효하지 않은 사용자 정보 또는 비밀번호입니다."), // 계정 관련 - DUPLICATED_REAL_ID(HttpStatus.CONFLICT, "ACCOUNT-001", "이미 존재하는 아이디입니다."), - USER_NOT_FOUND_BY_REAL_ID(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 아이디의 회원을 찾을 수 없습니다."), - FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "AUTH-011", "잘못된 접근입니다."), - + USER_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-001", "해당 아이디의 회원을 찾을 수 없습니다."), + ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "ACCOUNT-002", "이미 다른 사용자와 연동된 소셜 계정입니다."), + SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-003", "연동된 소셜 계정 정보를 찾을 수 없습니다."), + + // 소셜 로그인 관련 + OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "OAUTH-002", "지원하지 않는 소셜 로그인 제공자입니다."), + EMAIL_NOT_PROVIDED(HttpStatus.BAD_REQUEST, "OAUTH-003", "소셜 플랫폼에서 이메일 정보를 제공하지 않습니다."), + OAUTH_COMMUNICATION_FAILED(HttpStatus.BAD_GATEWAY, "OAUTH-004", "소셜 플랫폼과의 통신에 실패했습니다."), + SOCIAL_TOKEN_REFRESH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-005", "소셜 플랫폼의 토큰 갱신에 실패했습니다."), + SOCIAL_ACCOUNT_CONNECT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-006", "소셜 계정 연동에 실패했습니다."), + GITHUB_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "GitHub 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), // S3 파일 관련 // Client Errors (4xx) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java new file mode 100644 index 0000000..f13039e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class OAuthException extends RuntimeException { + ErrorCode errorCode; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java new file mode 100644 index 0000000..ba8f896 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +@Builder +public class OAuth2UserDto { + + private String oauthName; + private String nickname; + private String name; + private String email; + private String role; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java new file mode 100644 index 0000000..3f3bcef --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +import java.util.Map; + +public class GitHubResponseDto implements OAuth2ResponseDto { + + private final Map attributes; + + public GitHubResponseDto(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "github"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + return attributes.get("email") != null ? attributes.get("email").toString() : null; + } + + @Override + public String getName() { + return attributes.get("name") != null ? attributes.get("name").toString() : attributes.get("login").toString(); + } + + @Override + public String getProfileImageUrl() { + return attributes.get("avatar_url").toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java new file mode 100644 index 0000000..d93eba6 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java @@ -0,0 +1,36 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +import java.util.Map; + +public class GoogleResponseDto implements OAuth2ResponseDto { + private final Map attributes; + + public GoogleResponseDto(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attributes.get("sub").toString(); + } + + @Override + public String getEmail() { + return attributes.get("email").toString(); + } + + @Override + public String getName() { + return attributes.get("name").toString(); + } + + @Override + public String getProfileImageUrl() { + return attributes.get("picture").toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java new file mode 100644 index 0000000..dd1a667 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +// 제공자마다 반환 형태가 달라서 interface 생성. 제공자별 구현체 필요 +public interface OAuth2ResponseDto { + + // 제공자 (Ex. google, github) + String getProvider(); + + // 제공자가 발급해주는 고유 ID + String getProviderId(); + + String getEmail(); + + String getName(); + + String getProfileImageUrl(); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java new file mode 100644 index 0000000..a79f1e1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +import com.teamEWSN.gitdeun.user.entity.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class CustomOAuth2User implements OAuth2User { + + private final Long userId; + private final Role role; + + public CustomOAuth2User(Long userId, Role role) { + this.userId = userId; + this.role = role; + } + + @Override + public Map getAttributes() { + // 인증 성공 후 시스템 내부에서만 사용, 소셜 플랫폼의 attributes 필요 x + return Collections.emptyMap(); + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(this.role.name())); + } + + @Override + public String getName() { + // Spring Security에서 Principal의 이름을 식별하기 위해 우리 서비스의 고유 ID로 사용 + return String.valueOf(this.userId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java new file mode 100644 index 0000000..f573228 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +public enum OauthProvider { + GOOGLE, + GITHUB, +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java new file mode 100644 index 0000000..9b1ce2f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +import com.teamEWSN.gitdeun.common.converter.CryptoConverter; +import com.teamEWSN.gitdeun.common.util.BaseEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "social_connection") +public class SocialConnection extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + private OauthProvider provider; + + @Column(name = "provider_id", nullable = false) + private String providerId; + + @Convert(converter = CryptoConverter.class) + @Column(name = "access_token", length = 1024, nullable = false) + private String accessToken; + + @Convert(converter = CryptoConverter.class) + @Column(name = "refresh_token", length = 1024) + private String refreshToken; + + + @Builder + public SocialConnection(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + this.user = user; + this.provider = provider; + this.providerId = providerId; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public void updateTokens(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken != null ? refreshToken : this.refreshToken; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java new file mode 100644 index 0000000..1d66c20 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.common.oauth.handler; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.OAuthException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomOAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) { + + // 예외 유형에 따른 적절한 ErrorCode 설정 + ErrorCode errorCode; + + if (exception instanceof BadCredentialsException) { + // 잘못된 자격 증명 (비밀번호 오류) + errorCode = ErrorCode.INVALID_CREDENTIALS; + + } else if (exception instanceof InsufficientAuthenticationException) { + // 인증에 필요한 비밀 키가 유효하지 않음 + errorCode = ErrorCode.INVALID_SECRET_KEY; + + } else { + // 기본적인 Access Denied 처리 + errorCode = ErrorCode.ACCESS_DENIED; + } + + throw new OAuthException(errorCode); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java new file mode 100644 index 0000000..ae88585 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java @@ -0,0 +1,46 @@ +package com.teamEWSN.gitdeun.common.oauth.handler; + +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final CookieUtil cookieUtil; + + @Value("${app.front-url}") + private String frontUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); + + // 우리 서비스의 JWT 생성 + JwtToken jwtToken = jwtTokenProvider.generateToken(oAuth2User.getUserId(), oAuth2User.getRole()); + log.info("JWT가 발급되었습니다. Access Token: {}", jwtToken.getAccessToken()); + + // Refresh Token은 HttpOnly 쿠키에 저장 + cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpiredSeconds()); + + // Access Token은 쿼리 파라미터로 프론트엔드에 전달 + String targetUrl = UriComponentsBuilder.fromUriString(frontUrl + "/oauth/callback") + .queryParam("accessToken", jwtToken.getAccessToken()) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java new file mode 100644 index 0000000..ebfda31 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.common.oauth.repository; + +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SocialConnectionRepository extends JpaRepository { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..fe639c8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -0,0 +1,134 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.user.dto.provider.GitHubResponseDto; +import com.teamEWSN.gitdeun.user.dto.provider.GoogleResponseDto; +import com.teamEWSN.gitdeun.user.dto.provider.OAuth2ResponseDto; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.user.entity.Role; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final SocialConnectionRepository socialConnectionRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User; + try { + oAuth2User = super.loadUser(userRequest); + } catch (Exception e) { + // 외부 소셜 플랫폼과의 통신 자체에서 에러가 발생한 경우 + log.error("OAuth2 사용자 정보를 불러오는 데 실패했습니다.", e); + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } + + User user = processUserInTransaction(oAuth2User, userRequest); + return new CustomOAuth2User(user.getId(), user.getRole()); + } + + @Transactional + public User processUserInTransaction(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + OAuth2ResponseDto oAuth2ResponseDto = getOAuth2ResponseDto(oAuth2User, userRequest); + + // 이메일 정보가 없는 경우 예외 처리 (GitHub 등) + if (oAuth2ResponseDto.getEmail() == null) { + throw new GlobalException(ErrorCode.EMAIL_NOT_PROVIDED); + } + + OauthProvider provider = OauthProvider.valueOf(oAuth2ResponseDto.getProvider().toUpperCase()); + String providerId = oAuth2ResponseDto.getProviderId(); + String accessToken = userRequest.getAccessToken().getTokenValue(); + String refreshToken = (String) userRequest.getAdditionalParameters().get("refresh_token"); + + return socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .map(connection -> { + log.info("기존 소셜 계정 정보를 업데이트합니다: {}", provider); + connection.updateTokens(accessToken, refreshToken); + return connection.getUser(); + }) + .orElseGet(() -> { + // 다른 사용자가 이미 해당 이메일을 사용 중인지 확인 + userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) + .ifPresent(existingUser -> { + // 이메일은 같지만, 소셜 연동 정보가 없는 경우 -> 계정 연동 + log.info("기존 회원 계정에 소셜 계정을 연동합니다: {}", provider); + connectSocialAccount(existingUser, provider, providerId, accessToken, refreshToken); + }); + // 위에서 연동했거나, 완전 신규 유저인 경우를 처리 + // 다시 이메일로 조회하여 최종 유저를 반환하거나 새로 생성 + return userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) + .orElseGet(() -> { + log.info("신규 회원 및 소셜 계정을 생성합니다: {}", provider); + return createNewUser(oAuth2ResponseDto, provider, providerId, accessToken, refreshToken); + }); + }); + } + + private static OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + OAuth2ResponseDto oAuth2ResponseDto; + if (registrationId.equalsIgnoreCase("google")) { + oAuth2ResponseDto = new GoogleResponseDto(oAuth2User.getAttributes()); + } else if (registrationId.equalsIgnoreCase("github")) { + oAuth2ResponseDto = new GitHubResponseDto(oAuth2User.getAttributes()); + } else { + // 지원하지 않는 소셜 로그인 제공자 + throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + return oAuth2ResponseDto; + } + + private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + User newUser = User.builder() + .email(response.getEmail()) + .name(response.getName()) + .nickname(response.getName() + "_" + UUID.randomUUID().toString().substring(0, 6)) + .profileImage(response.getProfileImageUrl()) + .role(Role.ROLE_USER) + .build(); + + connectSocialAccount(newUser, provider, providerId, accessToken, refreshToken); + return userRepository.save(newUser); + } + + private void connectSocialAccount(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .ifPresent(connection -> { + // 이 소셜 계정이 이미 다른 유저와 연결되어 있다면 예외 발생 + if (!connection.getUser().getId().equals(user.getId())) { + throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } + }); + + SocialConnection connection = SocialConnection.builder() + .user(user) + .provider(provider) + .providerId(providerId) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + socialConnectionRepository.save(connection); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java new file mode 100644 index 0000000..788cc99 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class GitHubApiHelper { + + private final WebClient webClient; + + @Value("${spring.security.oauth2.client.registration.github.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.github.client-secret}") + private String clientSecret; + + public Mono revokeToken(String accessToken) { + String revokeUrl = "https://api.github.com/applications/" + clientId + "/token"; + + String credentials = Base64.getEncoder().encodeToString( + (clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8) + ); + + return webClient.post() + .uri(revokeUrl) + .header(HttpHeaders.AUTHORIZATION, "Basic " + credentials) + .header(HttpHeaders.ACCEPT, "application/vnd.github.v3+json") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("access_token", accessToken)) + .retrieve() + .bodyToMono(Void.class); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java new file mode 100644 index 0000000..cdfdb1d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -0,0 +1,21 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class GoogleApiHelper { + + private final WebClient webClient; + + public Mono revokeToken(String accessToken) { + String revokeUrl = "https://accounts.google.com/o/oauth2/revoke"; + return webClient.post() + .uri(uriBuilder -> uriBuilder.path(revokeUrl).queryParam("token", accessToken).build()) + .retrieve() + .bodyToMono(Void.class); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 551cef6..c4f3cd2 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -4,12 +4,21 @@ spring: data: redis: host: localhost + security: + oauth2: + client: + registration: + google: + redirect-uri: http://localhost:8080/login/oauth2/code/google + github: + redirect-uri: http://localhost:8080/login/oauth2/code/github jwt: access-expired: 28800 # 8시간 refresh-expired: 86400 # 1일 app: + front-url: http://localhost:3000 cookie: secure: false same-site: Lax \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index ccd364f..52805c5 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,6 +8,14 @@ spring: redis: host: gitdeun-redis password: ${REDIS_PASSWORD} + security: + oauth2: + client: + registration: + google: + redirect-uri: https://api.gitdeun.ac.kr/login/oauth2/code/google + github: + redirect-uri: https://api.gitdeun.ac.kr/login/oauth2/code/github jwt: @@ -15,6 +23,7 @@ jwt: refresh-expired: 604800 # 7일(초 단위: 7 * 24 * 60 * 60) app: + front-url: https://gitdeun.ac.kr cookie: secure: true same-site: None \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5873ef7..b3a03bc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,10 +9,10 @@ spring: password: ${MYSQL_PASSWORD} hikari: connection-timeout: 30000 # 30초 연결 제한 -# h2: -# console: -# enabled: true # H2 웹 콘솔 활성화 -# path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) + # h2: + # console: + # enabled: true # H2 웹 콘솔 활성화 + # path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) jpa: show-sql: true # SQL 로그 출력 hibernate: @@ -26,9 +26,23 @@ spring: order_updates: true jdbc: batch_size: 1000 + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: profile, email + github: + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} + scope: user:email, repo profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정 +db: + crypto-key: ${CRYPTO_KEY} jwt: secret-key: ${JWT_SECRET_KEY} From aeec789f9f8322be0a011dde82815a5e8977b84c Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 16 Jul 2025 15:21:11 +0900 Subject: [PATCH 11/60] =?UTF-8?q?feat:=20oauth=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=9E=90=EB=8F=99=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SocialTokenRefreshService.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java new file mode 100644 index 0000000..b3ec4d1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -0,0 +1,142 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Map; + +// 레포 및 마인드맵 호출 시 소셜로그인 토큰 갱신 호출 +@Slf4j +@Service +@RequiredArgsConstructor +public class SocialTokenRefreshService { + + private final SocialConnectionRepository socialConnectionRepository; + private final WebClient webClient; + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper + + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String googleClientId; + + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String googleClientSecret; + + + // oauth 토큰 갱신 + public void refreshSocialToken(Long userId, OauthProvider provider) { + SocialConnection connection = socialConnectionRepository.findByUserIdAndProvider(userId, provider) + .orElseThrow(() -> new GlobalException(ErrorCode.SOCIAL_CONNECTION_NOT_FOUND)); + + switch (provider) { + case GOOGLE -> refreshGoogleToken(connection); + case GITHUB -> { + log.warn("GitHub는 토큰 갱신을 지원하지 않습니다. 재인증이 필요합니다."); + throw new GlobalException(ErrorCode.GITHUB_TOKEN_REFRESH_NOT_SUPPORTED); + } + } + } + + private void refreshGoogleToken(SocialConnection connection) { + if (connection.getRefreshToken() == null) { + throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + try { + // Google Token 갱신 API 호출 + String tokenUrl = "https://oauth2.googleapis.com/token"; + + Map refreshRequest = Map.of( + "client_id", googleClientId, + "client_secret", googleClientSecret, + "refresh_token", connection.getRefreshToken(), + "grant_type", "refresh_token" + ); + + String response = webClient.post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(refreshRequest) + .retrieve() + .bodyToMono(String.class) + .block(); // 결과를 동기적으로 기다림 + + JsonNode tokenNode = objectMapper.readTree(response); + + String newAccessToken = tokenNode.get("access_token").asText(); + // 구글은 리프레시 토큰을 갱신하면 기존 리프레시 토큰을 다시 주지 않는 경우가 대부분 + String newRefreshToken = tokenNode.has("refresh_token") ? + tokenNode.get("refresh_token").asText() : connection.getRefreshToken(); + + connection.updateTokens(newAccessToken, newRefreshToken); + socialConnectionRepository.save(connection); + + log.info("Google 토큰 갱신 완료: userId={}", connection.getUser().getId()); + + } catch (Exception e) { + log.error("Google 토큰 갱신 실패: userId={}, error={}", + connection.getUser().getId(), e.getMessage()); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_FAILED); + } + } + + /** + * 토큰 유효성 검증 + */ + public boolean isTokenValid(String accessToken, OauthProvider provider) { + try { + return switch (provider) { + case GOOGLE -> validateGoogleToken(accessToken); + case GITHUB -> validateGitHubToken(accessToken); + }; + } catch (Exception e) { + log.error("토큰 유효성 검증 실패: provider={}, error={}", provider, e.getMessage()); + return false; + } + } + + private boolean validateGoogleToken(String accessToken) { + String validateUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo"; + + try { + webClient.get() + .uri(uriBuilder -> uriBuilder + .path(validateUrl) + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + return true; + } catch (Exception e) { + return false; + } + } + + private boolean validateGitHubToken(String accessToken) { + String validateUrl = "https://api.github.com/user"; + + try { + webClient.get() + .uri(validateUrl) + .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) + .retrieve() + .bodyToMono(String.class) + .block(); + return true; + } catch (Exception e) { + return false; + } + } +} From 34b044a1976cf2b2fdc1d9b7518d806cce494f60 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 16 Jul 2025 15:21:30 +0900 Subject: [PATCH 12/60] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=ED=83=88=ED=87=B4?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 38 +++++++++- .../gitdeun/user/service/UserService.java | 70 ++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java index 4a54934..aaba3ee 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java @@ -1,5 +1,41 @@ package com.teamEWSN.gitdeun.user.controller; +import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; +import com.teamEWSN.gitdeun.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/api/users/me") +@RequiredArgsConstructor public class UserController { - + + private final UserService userService; + private final CustomOAuth2UserService customOAuth2UserService; + + // 개인 정보 조회 + @GetMapping + public ResponseEntity getMyInfo(@AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); + UserResponseDto userInfo = userService.getMyInfo(userId); + + return ResponseEntity.ok(userInfo); + } + + + // 현재 회원 정보를 바탕으로 회원 탈퇴 + @DeleteMapping + public ResponseEntity deleteCurrentUser(@AuthenticationPrincipal CustomUserDetails userDetails) { + userService.deleteUser(userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java index 45bcdea..54077bb 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java @@ -1,5 +1,73 @@ package com.teamEWSN.gitdeun.user.service; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; +import com.teamEWSN.gitdeun.common.oauth.service.GoogleApiHelper; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor public class UserService { - + + private final UserRepository userRepository; + private final GoogleApiHelper googleApiHelper; + private final GitHubApiHelper gitHubApiHelper; + + // 회원 정보 조회 + @Transactional(readOnly = true) + public UserResponseDto getMyInfo(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + return userMapper.toResponseDto(user); + } + + + // 로그인된 회원 탈퇴 처리 + @Transactional + public void deleteUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + List connections = user.getSocialConnections(); + + // 모든 소셜 연동 해제 시도 + for (SocialConnection connection : connections) { + try { + switch (connection.getProvider()) { + case GOOGLE: + googleApiHelper.revokeToken(connection.getAccessToken()).block(); + log.info("Google token for user {} has been revoked.", userId); + break; + case GITHUB: + gitHubApiHelper.revokeToken(connection.getAccessToken()).block(); + log.info("GitHub token for user {} has been revoked.", userId); + break; + default: + log.warn("Unsupported provider for token revocation: {}", connection.getProvider()); + } + } catch (Exception e) { + // 특정 플랫폼 연동 해제에 실패하더라도, 다른 플랫폼 및 DB 처리는 계속 진행하기 위해 로그만 남김 + log.error("Failed to revoke token for provider {} and user {}: {}", + connection.getProvider(), userId, e.getMessage()); + } + } + + // 깃든 서비스 DB에서 soft-delete 처리 + user.markAsDeleted(); + userRepository.save(user); + log.info("User {} has been marked as deleted.", userId); + } + + } \ No newline at end of file From 46f9b44349fa5c1055b74900993ea9e101441804 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 17 Jul 2025 04:11:57 +0900 Subject: [PATCH 13/60] =?UTF-8?q?feat:=20SecurityConfig=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 경로별 인가 작업 - 인증 및 인가 실패 예외 처리 - jwt필터 포함 Security Filter 적용 - CORS 설정 --- .../gitdeun/common/config/SecurityConfig.java | 105 ++++++++++++++++++ src/main/resources/application-prod.yml | 6 +- 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java new file mode 100644 index 0000000..9f64457 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -0,0 +1,105 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.jwt.*; +import com.teamEWSN.gitdeun.common.jwt.CustomAccessDeniedHandler; +import lombok.RequiredArgsConstructor; +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.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.CorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private final CustomUserDetailsService customUserDetailsService; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf((auth) -> auth.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .formLogin((auth) -> auth.disable()) + .httpBasic((auth) -> auth.disable()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers((headerConfig) -> headerConfig + .frameOptions(frameOptionsConfig -> frameOptionsConfig.disable())); + + // 경로별 인가 작업 + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") + .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") + .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() + .anyRequest().permitAll() + ); + + // 예외 처리 + http + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) // 인증 실패 처리 + .accessDeniedHandler(customAccessDeniedHandler)); // 인가 실패 처리 + + // JwtFilter 추가 + http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // CORS 설정을 위한 Bean 등록 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration(); + configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 + configuration.addAllowedOrigin("https://gitdeun.netlify.app"); + configuration.addAllowedOrigin("https://gitdeun.site"); // 혜택온 도메인 + configuration.addAllowedOrigin("https://www.gitdeun.site"); + configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 + configuration.addAllowedHeader("*"); // 모든 헤더 허용 + configuration.setAllowedHeaders(java.util.List.of("Authorization", "Content-Type")); + configuration.setExposedHeaders(java.util.List.of("Authorization")); + configuration.setAllowCredentials(true); // 인증 정보 허용 (쿠키 등) + + org.springframework.web.cors.UrlBasedCorsConfigurationSource source = new org.springframework.web.cors.UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 적용 + return source; + } + + // Authentication manager + @Bean + public AuthenticationManager authenticationManager( + HttpSecurity http, + PasswordEncoder passwordEncoder) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder + = http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder + .userDetailsService(customUserDetailsService) + .passwordEncoder(passwordEncoder); + + return authenticationManagerBuilder.build(); + } + + // 비밀번호 암호화 저장을 위한 Encoder Bean 등록 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 52805c5..4ea34d0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -13,9 +13,9 @@ spring: client: registration: google: - redirect-uri: https://api.gitdeun.ac.kr/login/oauth2/code/google + redirect-uri: https://api.gitdeun.site/login/oauth2/code/google github: - redirect-uri: https://api.gitdeun.ac.kr/login/oauth2/code/github + redirect-uri: https://api.gitdeun.site/login/oauth2/code/github jwt: @@ -23,7 +23,7 @@ jwt: refresh-expired: 604800 # 7일(초 단위: 7 * 24 * 60 * 60) app: - front-url: https://gitdeun.ac.kr + front-url: https://gitdeun.site cookie: secure: true same-site: None \ No newline at end of file From 883b2f8df737fb1e43fec60b14a25cfa534dd4e3 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 17 Jul 2025 04:12:33 +0900 Subject: [PATCH 14/60] =?UTF-8?q?feat:=20QuerydslConfig=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 23 ++++++++++++++++++- .../teamEWSN/gitdeun/GitdeunApplication.java | 5 ++++ .../gitdeun/common/config/QuerydslConfig.java | 19 +++++++++++++++ .../{UserDto.java => UserResponseDto.java} | 2 +- 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java rename src/main/java/com/teamEWSN/gitdeun/user/dto/{UserDto.java => UserResponseDto.java} (59%) diff --git a/build.gradle b/build.gradle index 0c7b620..fbbe5b2 100644 --- a/build.gradle +++ b/build.gradle @@ -25,10 +25,11 @@ repositories { ext { mapstructVersion = "1.6.3" - springDocVersion = "2.5.0" + springDocVersion = "2.8.9" jjwtVersion = "0.12.6" awsSdkVersion = "2.25.68" springCloudAwsVersion = "3.1.1" + queryDslVersion = "5.1.0" } dependencies { @@ -80,6 +81,15 @@ dependencies { implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}") implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + // queryDsl + implementation "com.querydsl:querydsl-core:${queryDslVersion}" + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" + annotationProcessor ( + "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta", + // JPA 메타모델 생성용 + "jakarta.persistence:jakarta.persistence-api:3.1.0" + ) + // db runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' @@ -98,6 +108,17 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +/** Q 클래스 생성 경로 지정 **/ +def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile + +sourceSets { + main { java.srcDirs += querydslDir } +} + +tasks.withType(JavaCompile).configureEach { + options.annotationProcessorGeneratedSourcesDirectory = querydslDir +} + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java b/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java index c3c381b..d0cd466 100644 --- a/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java +++ b/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java @@ -2,7 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class GitdeunApplication { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java new file mode 100644 index 0000000..15c6b32 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java similarity index 59% rename from src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java rename to src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java index 0d992c8..412a148 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java @@ -1,5 +1,5 @@ package com.teamEWSN.gitdeun.user.dto; -public class UserDto { +public class UserResponseDto { } \ No newline at end of file From a651a2e247d54ea28773961fa09b16dfa90f0ced Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 17 Jul 2025 04:13:26 +0900 Subject: [PATCH 15/60] =?UTF-8?q?feat:=20OAuth2UserDto=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java | 2 ++ .../com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 9eb1a18..471f652 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -15,6 +15,8 @@ public enum ErrorCode { ACCESS_DENIED(HttpStatus.UNAUTHORIZED, "AUTH-005", "인증되지 않은 유저입니다."), INVALID_SECRET_KEY(HttpStatus.UNAUTHORIZED, "AUTH-006", "유효하지 않은 비밀 키입니다."), INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH-007", "유효하지 않은 사용자 정보 또는 비밀번호입니다."), + ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-008", "권한 정보가 없습니다."), + NO_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-009", "토큰이 존재하지 않습니다."), // 계정 관련 USER_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-001", "해당 아이디의 회원을 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java index ba8f896..f0cf9ad 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java @@ -8,10 +8,9 @@ @Getter @Builder public class OAuth2UserDto { - - private String oauthName; private String nickname; private String name; private String email; private String role; + private String profileImage; } From aec935bfca2ce308123ff7746818651dd73a19fc Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 17 Jul 2025 05:26:31 +0900 Subject: [PATCH 16/60] =?UTF-8?q?feat:=20JWT=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D/=EC=9D=B8=EA=B0=80=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20+=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/jwt/CustomAccessDeniedHandler.java | 52 +++++++ .../jwt/CustomAuthenticationEntryPoint.java | 52 +++++++ .../gitdeun/common/jwt/CustomUserDetails.java | 79 ++++++++++ .../common/jwt/CustomUserPrincipal.java | 14 ++ .../common/jwt/JwtAuthenticationFilter.java | 131 ++++++++++++++++ .../teamEWSN/gitdeun/common/jwt/JwtToken.java | 14 ++ .../gitdeun/common/jwt/JwtTokenParser.java | 45 ++++++ .../gitdeun/common/jwt/JwtTokenProvider.java | 145 ++++++++++++++++++ .../gitdeun/common/jwt/RefreshToken.java | 27 ++++ .../common/jwt/RefreshTokenRepository.java | 6 + .../common/jwt/RefreshTokenService.java | 37 +++++ .../gitdeun/common/util/AuthenticateUser.java | 26 ++++ 12 files changed, 628 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..47c9be1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.ErrorResponse; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +// 권한이 부족한 사용자를 접근 금지 역할(로그인 유무와 상관없이 권한이 없는 경우) +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.error("No Authorities", accessDeniedException); + log.error("Request Uri : {}", request.getRequestURI()); + + // ErrorCode 정의 + ErrorCode errorCode = ErrorCode.FORBIDDEN_ACCESS; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + // 응답 본문에 JSON 데이터 작성 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..a7873e2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +// 인증되지 않은 사용자의 출입 금지 역할(JWT 없이 접근시) +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + log.error("Not Authenticated Request", authException); + log.error("Request Uri : {}", request.getRequestURI()); + + // UNAUTHORIZED ErrorCode 사용 + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(errorCode.getHttpStatus().value()); + response.setCharacterEncoding("UTF-8"); + + // JSON 응답 반환 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java new file mode 100644 index 0000000..75c3179 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java @@ -0,0 +1,79 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import com.teamEWSN.gitdeun.user.entity.Role; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +/** + * 인증된 사용자의 신분증(사용자의 정보) + * JwtAuthenticationFilter 토큰 정보를 바탕으로 객체 생성 + */ +@Getter +public class CustomUserDetails implements UserDetails, CustomUserPrincipal { + private final Long id; + private final String email; + private final String nickname; + private final String profileImage; + private final Role role; + private final String name; + + public CustomUserDetails(Long id, String email, String nickname, String profileImage, Role role, String name) { + this.id = id; + this.email = email; + this.nickname = nickname; + this.profileImage = profileImage; + this.role = role; + this.name = name; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(role.name())); // 문자열 기반 권한 + } + + @Override + public String getRole() { + return role.name(); + } + + @Override + public Map getAttributes() { + return Collections.emptyMap(); // OAuth2가 아니므로 빈 맵 반환 + } + + @Override + public String getPassword() { + return null; // User 엔티티에 비밀번호가 없으므로 null 반환 + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java new file mode 100644 index 0000000..241a4fd --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import java.util.Map; + +public interface CustomUserPrincipal { + Long getId(); + String getEmail(); + String getNickname(); + String getRole(); + String getName(); + String getProfileImage(); + Map getAttributes(); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..576b4fa --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,131 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.ErrorResponse; +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; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collection; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Value("${jwt.secret-key}") + private String secretKey; + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private static final String BEARER = "Bearer"; + private static final String ADMIN_API_PREFIX = "/api/admin"; + + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String requestURI = request.getRequestURI(); + boolean isAdminRequest = requestURI.startsWith(ADMIN_API_PREFIX); + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + // access token이 있고, BEARER로 시작한다면 + if (authHeader != null && authHeader.startsWith(BEARER)) { + String token = authHeader.substring(BEARER.length()).trim(); // trim 추가 + + try { + // 토큰 검증 + if (jwtTokenProvider.validateToken(token)) { + // 유효한 토큰: 유저 정보 가져옴 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + + // 관리자 API 접근 시 추가 검증 + if (isAdminRequest && !hasAdminRole(authentication.getAuthorities())) { + log.warn("관리자 권한 없이 관리자 리소스에 접근 시도: {}", requestURI); + sendAccessDeniedResponse(response); + return; + } + + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + // 토큰이 유효하지 않은 경우 - 관리자 API는 차단, 일반 API는 진행 + if (isAdminRequest) { + log.warn("유효하지 않은 토큰으로 관리자 리소스 접근 시도: {}", requestURI); + sendUnauthorizedResponse(response); + return; + } + // 일반 API는 인증 없이도 접근 가능한 경우가 있으므로 계속 진행 + } + } catch (Exception e) { + // 토큰 처리 중 예외 발생 + log.error("JWT 토큰 처리 중 오류 발생 - URI: {}, Error: {}", requestURI, e.getMessage()); + + if (isAdminRequest) { + // 관리자 API는 예외 발생 시 차단 + sendUnauthorizedResponse(response); + return; + } + // 일반 API는 인증 실패로 처리하고 서비스 계속 진행 (SecurityContext 비워둠) + SecurityContextHolder.clearContext(); + } + } else if (isAdminRequest) { + // 관리자 API 접근 시 토큰 없으면 Unauthorized 응답 + log.warn("인증 없이 관리자 리소스에 접근 시도: {}", requestURI); + sendUnauthorizedResponse(response); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean hasAdminRole(Collection authorities) { + return authorities.stream() + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + } + + // 접근 거부 응답 처리(403 - 권한 없음) + private void sendAccessDeniedResponse(HttpServletResponse response) throws IOException { + log.warn("403 Forbidden - 접근 거부됨"); + + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + + } + + // 인증 실패 응답 처리 (401 - 인증 실패) + private void sendUnauthorizedResponse(HttpServletResponse response) throws IOException { + log.warn("401 Unauthorized - 인증 실패"); + + ErrorCode errorCode = ErrorCode.NO_TOKEN; + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java new file mode 100644 index 0000000..66a7849 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java new file mode 100644 index 0000000..af0bd43 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java @@ -0,0 +1,45 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; + +@Slf4j +@Component +public class JwtTokenParser { + + private final SecretKey secretKey; + + public JwtTokenParser(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + // Access Token에서 Claims 추출 + public Claims parseClaims(String accessToken) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + // 토큰에서 아이디 정보 추출 + public String getRealIdFromToken(String accessToken) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + return claims.getSubject(); + }} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..83e2de5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java @@ -0,0 +1,145 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.user.entity.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.UUID; + +@Slf4j +@Getter +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + + @Autowired + private RefreshTokenService refreshTokenService; + + @Autowired + private BlacklistService blackListService; + + @Autowired + private JwtTokenParser jwtTokenParser; + + @Value("${jwt.access-expired}") + private Long accessTokenExpired; + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + + public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + // 토큰 생성 - 유저 정보 이용 + public JwtToken generateToken(Authentication authentication) { + + long now = (new Date()).getTime(); + Date accessTokenExpiration = new Date(now + accessTokenExpired * 1000); + + CustomUserPrincipal userPrincipal = (CustomUserPrincipal) authentication.getPrincipal(); + + Long userId = ((CustomUserDetails) userPrincipal).getId(); + + String jti = UUID.randomUUID().toString(); + // Access Token 생성 + String accessToken = Jwts.builder() + .subject(String.valueOf(userId)) // Subject를 불변값인 userId로 설정 + .issuedAt(new Date()) // 발행 시간 + .id(jti) // blacklist 관리를 위한 jwt token id + .claim("email", userPrincipal.getEmail()) // 이메일 + .claim("nickname", userPrincipal.getNickname()) // 닉네임 + .claim("role", userPrincipal.getRole()) // 사용자 역할(Role) + .claim("name",userPrincipal.getName()) + .claim("profileImage", userPrincipal.getProfileImage()) // 프로필 이미지 추가 + .expiration(accessTokenExpiration) // 만료 시간 + .signWith(secretKey) // 서명 + .compact(); + + // Refresh Token 생성 (임의의 값 생성) + String refreshToken = UUID.randomUUID().toString(); + + // Redis에 Refresh Token 정보 저장 + refreshTokenService.saveRefreshToken( refreshToken, userPrincipal.getEmail(), refreshTokenExpired); + + + // JWT Token 객체 반환 + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + + } + + + // 토큰에서 유저 정보 추출 + public Authentication getAuthentication(String accessToken) { + // 토큰에서 Claims 추출 + Claims claims = jwtTokenParser.parseClaims(accessToken); + + // 권한 정보 확인 + if (claims.get("role") == null) { + throw new GlobalException(ErrorCode.ROLE_NOT_FOUND); + } + + // 클레임에서 모든 사용자 정보 추출 + Long id = Long.parseLong(claims.getSubject()); + String email = claims.get("email", String.class); + String nickname = claims.get("nickname", String.class); + String name = claims.get("name", String.class); + String profileImage = claims.get("profileImage", String.class); + Role role = Role.valueOf(claims.get("role", String.class)); + + CustomUserDetails userDetails = new CustomUserDetails(id, email, nickname, profileImage, role, name); + + Collection authorities = + Collections.singletonList(role::name); + + // Authentication 객체 반환 + return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + + } + + // 토큰 정보 검증 + public boolean validateToken(String token) { + log.debug("validateToken start"); + try { + Jws claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + + String jti = claims.getPayload().getId(); // JTI 추출 + return !blackListService.isTokenBlacklisted(jti); + + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.error("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.error("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.error("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty.", e); + } + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java new file mode 100644 index 0000000..a2ca41f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java @@ -0,0 +1,27 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@AllArgsConstructor +@Builder +@NoArgsConstructor +@RedisHash("refreshToken") +public class RefreshToken { + + @Id + private String refreshToken; + + private String email; + private Long issuedAt; + + // Time to live (TTL) 설정, Redis에 만료 시간을 설정 + @TimeToLive + private Long ttl; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java new file mode 100644 index 0000000..82b125f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java new file mode 100644 index 0000000..51e4818 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.common.jwt; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + public void saveRefreshToken(String refreshToken, String email, long refreshTokenExpired) { + RefreshToken token = RefreshToken.builder() + .refreshToken(refreshToken) + .email(email) + .issuedAt(System.currentTimeMillis()) + .ttl(refreshTokenExpired) // @TimeToLive에 사용될 만료 시간 + .build(); + + refreshTokenRepository.save(token); + } + + + public Optional getRefreshToken(String refreshToken) { + return refreshTokenRepository.findById(refreshToken); + } + + + public void deleteRefreshToken(String refreshToken) { + refreshTokenRepository.deleteById(refreshToken); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java new file mode 100644 index 0000000..1620c28 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java @@ -0,0 +1,26 @@ +package com.teamEWSN.gitdeun.common.util; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + + +/** + * 현재 인증된 사용자의 정보를 가져옴 + * 컨트롤러가 아닌 다른 서비스 계층 등에서 사용자 ID가 필요할 때 편리하게 사용 + */ +@Component +public class AuthenticateUser { + public Long authenticateUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) { + return 0L; // 인증되지 않은 사용자일 경우 0 반환 + } + + // 인증된 사용자의 경우 userId 반환 + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return userDetails.getId(); + } +} From 2c4761478b803c58368cd99bd30efb26efb37c4b Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 17 Jul 2025 05:36:45 +0900 Subject: [PATCH 17/60] =?UTF-8?q?feat:=20SecurityConfig=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0,=20JWT=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BlacklistService.java --- .../gitdeun/common/config/SecurityConfig.java | 51 ++++++--------- .../gitdeun/common/jwt/BlacklistService.java | 62 +++++++++++++++++++ 2 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java index 9f64457..8417d59 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -6,18 +6,18 @@ import lombok.RequiredArgsConstructor; 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.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 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.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; + @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -26,7 +26,6 @@ public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; private final ObjectMapper objectMapper; - private final CustomUserDetailsService customUserDetailsService; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomAccessDeniedHandler customAccessDeniedHandler; @@ -34,14 +33,14 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf((auth) -> auth.disable()) + .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .formLogin((auth) -> auth.disable()) - .httpBasic((auth) -> auth.disable()) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .headers((headerConfig) -> headerConfig - .frameOptions(frameOptionsConfig -> frameOptionsConfig.disable())); + .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); // 경로별 인가 작업 http @@ -50,6 +49,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() .anyRequest().permitAll() + // .anyRequest().authenticated() ); // 예외 처리 @@ -67,13 +67,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // CORS 설정을 위한 Bean 등록 @Bean public CorsConfigurationSource corsConfigurationSource() { - org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration(); - configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 - configuration.addAllowedOrigin("https://gitdeun.netlify.app"); - configuration.addAllowedOrigin("https://gitdeun.site"); // 혜택온 도메인 - configuration.addAllowedOrigin("https://www.gitdeun.site"); - configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 - configuration.addAllowedHeader("*"); // 모든 헤더 허용 + CorsConfiguration configuration = getCorsConfiguration(); configuration.setAllowedHeaders(java.util.List.of("Authorization", "Content-Type")); configuration.setExposedHeaders(java.util.List.of("Authorization")); configuration.setAllowCredentials(true); // 인증 정보 허용 (쿠키 등) @@ -83,23 +77,16 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } - // Authentication manager - @Bean - public AuthenticationManager authenticationManager( - HttpSecurity http, - PasswordEncoder passwordEncoder) throws Exception { - AuthenticationManagerBuilder authenticationManagerBuilder - = http.getSharedObject(AuthenticationManagerBuilder.class); - authenticationManagerBuilder - .userDetailsService(customUserDetailsService) - .passwordEncoder(passwordEncoder); - return authenticationManagerBuilder.build(); + private static CorsConfiguration getCorsConfiguration() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 + configuration.addAllowedOrigin("https://gitdeun.netlify.app"); + configuration.addAllowedOrigin("https://gitdeun.site"); // 혜택온 도메인 + configuration.addAllowedOrigin("https://www.gitdeun.site"); + configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 + configuration.addAllowedHeader("*"); // 모든 헤더 허용 + return configuration; } - // 비밀번호 암호화 저장을 위한 Encoder Bean 등록 - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java new file mode 100644 index 0000000..3625c39 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java @@ -0,0 +1,62 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BlacklistService { + + private final RedisTemplate redisTemplate; + private final JwtTokenParser jwtTokenParser; + + private static final String ACCESS_TOKEN_BLACKLIST_PREFIX="blacklist:access:"; + + public void addToBlacklist(String accessToken) { + // Access Token 만료 시간 계산 + Claims claims = jwtTokenParser.parseClaims(accessToken); + Date expiredDate = claims.getExpiration(); + String jti = claims.getId(); + long now = System.currentTimeMillis(); + long timeToLive = (expiredDate.getTime() - now) / 1000; + + if (timeToLive > 0) { + // Redis에 블랙리스트 등록 + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + redisTemplate.opsForValue().set(redisKey, "blacklisted", timeToLive, TimeUnit.SECONDS); + log.debug("Access Token 블랙리스트 추가 - JTI: {}, 만료 시간: {}초 후", jti, timeToLive); + } + + } + + + public boolean isTokenBlacklisted(String jti) { + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + try { + Boolean exists = redisTemplate.hasKey(redisKey); + if (Boolean.TRUE.equals(exists)) { + log.warn("블랙리스트에 있는 Access Token으로 접근 시도 중 - JTI: {}", jti); + } + return Boolean.TRUE.equals(exists); + } catch (Exception e) { + log.error("Redis 연결 중 오류 발생 - JTI: {}, 오류: {}", jti, e.getMessage()); + return false; // Redis가 죽었을 때 기본값을 false로 + } + } + + + public void removeFromBlacklist(String jti) { + String redisKey = "blacklist:access:" + jti; + redisTemplate.delete(redisKey); + log.debug("Access Token 블랙리스트에서 삭제 - JTI: {}", jti); + } + + +} From ff6db2ce7a1eb786e680ea562d6594cfe3e8fba7 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 18 Jul 2025 15:37:14 +0900 Subject: [PATCH 18/60] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EA=B5=AC?= =?UTF-8?q?=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserResponseDto 구현 - UserMapper 구현 - UserRepository 구현 - UserController, service 개선 --- .../user/controller/UserController.java | 25 ++++++++++++++----- .../gitdeun/user/dto/UserResponseDto.java | 17 ++++++++++++- .../gitdeun/user/mapper/UserMapper.java | 14 +++++++++++ .../user/repository/UserRepository.java | 11 +++++++- .../gitdeun/user/service/UserService.java | 21 ++++++++++++++-- 5 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java index aaba3ee..130ed5c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java @@ -1,15 +1,16 @@ package com.teamEWSN.gitdeun.user.controller; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; import com.teamEWSN.gitdeun.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -32,8 +33,20 @@ public ResponseEntity getMyInfo(@AuthenticationPrincipal Custom // 현재 회원 정보를 바탕으로 회원 탈퇴 @DeleteMapping - public ResponseEntity deleteCurrentUser(@AuthenticationPrincipal CustomUserDetails userDetails) { - userService.deleteUser(userDetails.getId()); + public ResponseEntity deleteCurrentUser( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + @RequestHeader("Authorization") String authHeader) { + // JwtAuthenticationFilter가 정상 동작했다면 userDetails는 절대 null이 아님 + if (refreshToken == null || refreshToken.isEmpty()) { + // 리프레시 토큰이 없는 경우에 대한 예외 처리 + throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + String accessToken = authHeader.replace("Bearer ", ""); + + userService.deleteUser(userDetails.getId(), accessToken, refreshToken); + return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java index 412a148..c05807e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java @@ -1,5 +1,20 @@ package com.teamEWSN.gitdeun.user.dto; +import com.teamEWSN.gitdeun.user.entity.Role; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder public class UserResponseDto { - + private Long id; // 사용자 ID + private String name; // 사용자 이름 + private String email; // 이메일 + private String nickname; // 닉네임 + private String profileImage; // image url + private Role role; // 권한 (USER/ADMIN 등) + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java new file mode 100644 index 0000000..a9672d7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.user.mapper; + +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface UserMapper { + + + UserResponseDto toResponseDto(User user); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index 8a8f3ac..3f4a861 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -4,7 +4,16 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { - + + // user id로 검색 + Optional findByIdAndDeletedAtIsNull(Long id); + + // user email로 검색 + Optional findByEmailAndDeletedAtIsNull(String email); + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java index 54077bb..9e2cd66 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java @@ -2,10 +2,14 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.jwt.BlacklistService; +import com.teamEWSN.gitdeun.common.jwt.RefreshTokenService; import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; import com.teamEWSN.gitdeun.common.oauth.service.GoogleApiHelper; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.mapper.UserMapper; import com.teamEWSN.gitdeun.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +22,9 @@ @Service @RequiredArgsConstructor public class UserService { - + private final UserMapper userMapper; + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; private final UserRepository userRepository; private final GoogleApiHelper googleApiHelper; private final GitHubApiHelper gitHubApiHelper; @@ -35,7 +41,7 @@ public UserResponseDto getMyInfo(Long userId) { // 로그인된 회원 탈퇴 처리 @Transactional - public void deleteUser(Long userId) { + public void deleteUser(Long userId, String accessToken, String refreshToken) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); @@ -63,11 +69,22 @@ public void deleteUser(Long userId) { } } + // redis에서 리프레시 토큰 삭제 + refreshTokenService.deleteRefreshToken(refreshToken); + // access token 블랙리스트에 등록 + blacklistService.addToBlacklist(accessToken); + // 깃든 서비스 DB에서 soft-delete 처리 user.markAsDeleted(); userRepository.save(user); log.info("User {} has been marked as deleted.", userId); } + // 이메일로 회원 검색 + @Transactional(readOnly = true) + public User findUserByEmail(String email) { + return userRepository.findByEmailAndDeletedAtIsNull(email) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_EMAIL)); + } } \ No newline at end of file From 3f8761d93a935c7b968da35726d61950ccf802e2 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 18 Jul 2025 15:42:48 +0900 Subject: [PATCH 19/60] =?UTF-8?q?refactor:=20OAuth=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application.yml 환경변수 수정 - OAuth관련 닉네임, 이름 정보 GET 방식 수정 - 외부 프록시로 접근 시 트랜잭션 미적용 수정 - SocialConnectionRepository 구현 - MultiValueMap을 사용한 webclient bodyValue 최적화 --- .../gitdeun/common/exception/ErrorCode.java | 5 +++-- .../oauth/dto/provider/GitHubResponseDto.java | 5 +++++ .../oauth/dto/provider/GoogleResponseDto.java | 6 ++++++ .../oauth/dto/provider/OAuth2ResponseDto.java | 2 ++ .../SocialConnectionRepository.java | 13 ++++++++++++ .../service/CustomOAuth2UserService.java | 20 +++++++++++++------ .../service/SocialTokenRefreshService.java | 20 +++++++++++-------- src/main/resources/application-dev.yml | 9 +++++++++ src/main/resources/application-prod.yml | 2 ++ src/main/resources/application.yml | 5 ++--- 10 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 471f652..1f432bd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -20,8 +20,9 @@ public enum ErrorCode { // 계정 관련 USER_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-001", "해당 아이디의 회원을 찾을 수 없습니다."), - ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "ACCOUNT-002", "이미 다른 사용자와 연동된 소셜 계정입니다."), - SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-003", "연동된 소셜 계정 정보를 찾을 수 없습니다."), + USER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 이메일의 회원을 찾을 수 없습니다."), + ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "ACCOUNT-003", "이미 다른 사용자와 연동된 소셜 계정입니다."), + SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-004", "연동된 소셜 계정 정보를 찾을 수 없습니다."), // 소셜 로그인 관련 OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java index 3f3bcef..eb7dcff 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java @@ -30,6 +30,11 @@ public String getName() { return attributes.get("name") != null ? attributes.get("name").toString() : attributes.get("login").toString(); } + @Override + public String getNickname() { + return attributes.get("login").toString(); + } + @Override public String getProfileImageUrl() { return attributes.get("avatar_url").toString(); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java index d93eba6..01da254 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java @@ -29,6 +29,12 @@ public String getName() { return attributes.get("name").toString(); } + @Override + public String getNickname() { + // 별도 닉네임이 없으므로 이름(name)을 그대로 반환 + return attributes.get("name").toString(); + } + @Override public String getProfileImageUrl() { return attributes.get("picture").toString(); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java index dd1a667..4204c25 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java @@ -13,6 +13,8 @@ public interface OAuth2ResponseDto { String getName(); + String getNickname(); + String getProfileImageUrl(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java index ebfda31..1546c75 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java @@ -1,9 +1,22 @@ package com.teamEWSN.gitdeun.common.oauth.repository; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface SocialConnectionRepository extends JpaRepository { + + /** + * 소셜 플랫폼과 해당 플랫폼에서의 고유 ID로 소셜 연동 정보를 조회 + * @param provider 소셜 플랫폼 (GOOGLE, GITHUB 등) + * @param providerId 해당 소셜 플랫폼에서의 사용자 고유 ID + */ + Optional findByProviderAndProviderId(OauthProvider provider, String providerId); + + // 소셜 로그인한 사용자의 소셜 연동 정보 조회 + Optional findByUserIdAndProvider(Long userId, OauthProvider provider); } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index fe639c8..cf9c64e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -2,9 +2,9 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.exception.ErrorCode; -import com.teamEWSN.gitdeun.user.dto.provider.GitHubResponseDto; -import com.teamEWSN.gitdeun.user.dto.provider.GoogleResponseDto; -import com.teamEWSN.gitdeun.user.dto.provider.OAuth2ResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GoogleResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.OAuth2ResponseDto; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.user.entity.Role; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; @@ -23,6 +23,7 @@ import java.util.UUID; + @Slf4j @Service @RequiredArgsConstructor @@ -32,6 +33,7 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final SocialConnectionRepository socialConnectionRepository; @Override + @Transactional public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User; try { @@ -46,7 +48,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic return new CustomOAuth2User(user.getId(), user.getRole()); } - @Transactional + // @Transactional public User processUserInTransaction(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { OAuth2ResponseDto oAuth2ResponseDto = getOAuth2ResponseDto(oAuth2User, userRequest); @@ -100,10 +102,16 @@ private static OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAu } private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + // provider별 다른 Nickname 처리 로직 + String nickname = response.getNickname(); + if (provider == OauthProvider.GOOGLE) { + nickname = nickname + "_" + UUID.randomUUID().toString().substring(0, 6); + } + User newUser = User.builder() .email(response.getEmail()) - .name(response.getName()) - .nickname(response.getName() + "_" + UUID.randomUUID().toString().substring(0, 6)) + .name(response.getName()) // GitHub의 경우 full name, Google의 경우 name + .nickname(nickname) .profileImage(response.getProfileImageUrl()) .role(Role.ROLE_USER) .build(); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java index b3ec4d1..d026f47 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -15,7 +15,10 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; -import java.util.Map; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; + // 레포 및 마인드맵 호출 시 소셜로그인 토큰 갱신 호출 @Slf4j @@ -57,17 +60,18 @@ private void refreshGoogleToken(SocialConnection connection) { // Google Token 갱신 API 호출 String tokenUrl = "https://oauth2.googleapis.com/token"; - Map refreshRequest = Map.of( - "client_id", googleClientId, - "client_secret", googleClientSecret, - "refresh_token", connection.getRefreshToken(), - "grant_type", "refresh_token" - ); + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", googleClientId); + formData.add("client_secret", googleClientSecret); + formData.add("refresh_token", connection.getRefreshToken()); + formData.add("grant_type", "refresh_token"); + String response = webClient.post() .uri(tokenUrl) .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .bodyValue(refreshRequest) + .bodyValue(BodyInserters.fromFormData(formData)) .retrieve() .bodyToMono(String.class) .block(); // 결과를 동기적으로 기다림 diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c4f3cd2..69e048c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -11,7 +11,16 @@ spring: google: redirect-uri: http://localhost:8080/login/oauth2/code/google github: + client-id: ${GITHUB_DEV_CLIENT_ID} + client-secret: ${GITHUB_DEV_CLIENT_SECRET} redirect-uri: http://localhost:8080/login/oauth2/code/github +# provider: +# google: +# authorization-uri: https://accounts.google.com/o/oauth2/v2/auth +# token-uri: https://oauth2.googleapis.com/token +# user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo +# user-name-attribute: sub + jwt: access-expired: 28800 # 8시간 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4ea34d0..a8081ce 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -15,6 +15,8 @@ spring: google: redirect-uri: https://api.gitdeun.site/login/oauth2/code/google github: + client-id: ${GITHUB_PROD_CLIENT_ID} + client-secret: ${GITHUB_PROD_CLIENT_SECRET} redirect-uri: https://api.gitdeun.site/login/oauth2/code/github diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b3a03bc..7430f06 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,9 +35,7 @@ spring: client-secret: ${GOOGLE_CLIENT_SECRET} scope: profile, email github: - client-id: ${GITHUB_CLIENT_ID} - client-secret: ${GITHUB_CLIENT_SECRET} - scope: user:email, repo + scope: read:user, user:email, repo profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정 @@ -45,4 +43,5 @@ db: crypto-key: ${CRYPTO_KEY} jwt: + issuer: Gitdeun.site secret-key: ${JWT_SECRET_KEY} From 427478724cf1746b3a05675cb93ad256dddab566 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 18 Jul 2025 15:47:55 +0900 Subject: [PATCH 20/60] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그아웃 HttpServletRequest 요청, 응답에서 인증헤더, refreshToken 파라미터로 변경 - 토큰 재발급 쿠키 설정 매개변수 변경 --- .../user/controller/AuthController.java | 20 ++++++-- .../gitdeun/user/service/AuthService.java | 50 +++++++++++-------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index 2addc32..93628d7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -1,9 +1,10 @@ package com.teamEWSN.gitdeun.user.controller; import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.common.jwt.JwtToken; import com.teamEWSN.gitdeun.user.dto.UserTokenResponseDto; import com.teamEWSN.gitdeun.user.service.AuthService; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,9 +28,18 @@ public class AuthController { // 로그아웃 API @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { - authService.logout(request, response); - return ResponseEntity.noContent().build(); + public ResponseEntity logout( + @RequestHeader("Authorization") String authHeader, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + // 헤더에서 Access Token 추출 + String accessToken = authHeader.replace("Bearer ", ""); + + // 로그아웃 로직 - AccessToken: Blacklist 등록, RefreshToken: redis에서 삭제 및 쿠키 제거 + authService.logout(accessToken, refreshToken, response); + + return ResponseEntity.noContent().build(); // 204 No Content 응답 } // 토큰 재발급 @@ -39,7 +49,7 @@ public ResponseEntity refreshAccessToken( HttpServletResponse response) { JwtToken newJwtToken = authService.refreshTokens(refreshToken); - cookieUtil.setCookie(response, "refreshToken", newJwtToken.getRefreshToken(), authService.getRefreshTokenExpiredSeconds()); + cookieUtil.setCookie(response, "refreshToken", newJwtToken.getRefreshToken(), refreshTokenExpired); return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index 1e9b516..94ed87f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -1,17 +1,16 @@ package com.teamEWSN.gitdeun.user.service; -import com.nimbusds.oauth2.sdk.token.RefreshToken; +import com.teamEWSN.gitdeun.common.jwt.RefreshToken; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.*; import com.teamEWSN.gitdeun.user.entity.User; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; @@ -24,38 +23,43 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; + private final UserService userService; private final RefreshTokenRepository refreshTokenRepository; private final CookieUtil cookieUtil; @Value("${jwt.refresh-expired}") private Long refreshTokenExpired; - @Transactional - public void logout(HttpServletRequest request, HttpServletResponse response) { - String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION).replace("Bearer ", ""); - - // 1. Redis에서 Refresh Token 삭제 - refreshTokenRepository.findByAccessToken(accessToken) - .ifPresent(refreshTokenRepository::delete); - // 2. 쿠키에서 Refresh Token 삭제 + // 로그 아웃 + @Transactional + public void logout(String accessToken, String refreshToken, HttpServletResponse response) { + blacklistService.addToBlacklist(accessToken); + refreshTokenService.deleteRefreshToken(refreshToken); cookieUtil.deleteCookie(response, "refreshToken"); } + // 토큰 재발급 @Transactional - public JwtToken refreshTokens(String oldRefreshToken) { - // 1. Redis에서 Refresh Token 조회 및 유효성 검증 - RefreshToken refreshToken = refreshTokenRepository.findById(oldRefreshToken) + public JwtToken refreshTokens(String refreshToken) { + // Redis에서 Refresh Token 조회 + RefreshToken tokenDetails = refreshTokenService.getRefreshToken(refreshToken) .orElseThrow(() -> new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN)); - // 2. 새로운 토큰 생성 - JwtToken newJwtToken = jwtTokenProvider.generateToken(refreshToken.getUserId(), refreshToken.getRole()); + // 토큰에서 email 정보 추출 + String email = tokenDetails.getEmail(); - // 3. 기존 Refresh Token을 삭제하고 새로 발급된 토큰 정보로 저장 - refreshTokenRepository.delete(refreshToken); - refreshTokenRepository.save(new RefreshToken(newJwtToken.getRefreshToken(), refreshToken.getUserId(), refreshToken.getRole(), newJwtToken.getAccessToken())); + // email로 사용자 정보 조회 + User user = userService.findUserByEmail(email); + Authentication authentication = createAuthentication(user); - return newJwtToken; + // 기존 리프레시 토큰은 DB에서 제거 (순환) + refreshTokenService.deleteRefreshToken(refreshToken); + + // 새로운 Access/Refresh 토큰 생성 + return jwtTokenProvider.generateToken(authentication); } // connectSocialAccount 로직은 CustomOAuth2UserService의 로직과 유사하게 @@ -73,5 +77,11 @@ public void connectSocialAccount(Long userId, String provider, String code) { System.out.printf("계정 연동 시도: userId=%d, provider=%s, code=%s\n", userId, provider, code); } + // User 객체를 사용해 authentication 생성 + private Authentication createAuthentication(User user) { + CustomUserDetails customUserDetails = new CustomUserDetails( + user.getId(), user.getEmail(), user.getNickname(), user.getProfileImage(), user.getRole(), user.getName()); + return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + } } From 7ba0cd7ae24bc470929cac602756c47a043f0235 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 19 Jul 2025 16:23:11 +0900 Subject: [PATCH 21/60] =?UTF-8?q?feat:=20google=20->=20github=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/config/SecurityConfig.java | 22 +++++ .../handler/CustomOAuth2SuccessHandler.java | 7 +- .../common/oauth/service/GitHubApiHelper.java | 70 ++++++++++++++++ .../gitdeun/common/util/AuthenticateUser.java | 18 ++--- .../user/controller/AuthController.java | 19 ++--- .../gitdeun/user/service/AuthService.java | 80 +++++++++++++++---- src/main/resources/application.yml | 2 +- 7 files changed, 177 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java index 8417d59..56436f8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.common.jwt.*; import com.teamEWSN.gitdeun.common.jwt.CustomAccessDeniedHandler; +import com.teamEWSN.gitdeun.common.oauth.handler.CustomOAuth2FailureHandler; +import com.teamEWSN.gitdeun.common.oauth.handler.CustomOAuth2SuccessHandler; +import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,6 +27,9 @@ @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler; + private final CustomOAuth2FailureHandler customOAuthFailureHandler; private final JwtTokenProvider jwtTokenProvider; private final ObjectMapper objectMapper; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; @@ -42,6 +48,22 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .headers((headerConfig) -> headerConfig .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); + // oauth2 로그인 설정 + http + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig + .userService(customOAuth2UserService)) +// .defaultSuccessUrl("/oauth/success") // 로그인 성공시 이동할 URL + .successHandler(customOAuth2SuccessHandler) +// .failureUrl("/oauth/fail") // 로그인 실패시 이동할 URL + .failureHandler(customOAuthFailureHandler)) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/oauth/logout") // 로그아웃 성공시 해당 url로 이동 + .clearAuthentication(true) // 현재 요청의 SecurityContext 초기화 + .deleteCookies("refreshToken") // JWT RefreshToken 쿠키를 프론트에서 제거 명시 + ); + // 경로별 인가 작업 http .authorizeHttpRequests((auth) -> auth diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java index ae88585..03dc0aa 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java @@ -1,6 +1,8 @@ package com.teamEWSN.gitdeun.common.oauth.handler; import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.jwt.JwtTokenProvider; import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -27,14 +29,13 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); // 우리 서비스의 JWT 생성 - JwtToken jwtToken = jwtTokenProvider.generateToken(oAuth2User.getUserId(), oAuth2User.getRole()); + JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); log.info("JWT가 발급되었습니다. Access Token: {}", jwtToken.getAccessToken()); // Refresh Token은 HttpOnly 쿠키에 저장 - cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpiredSeconds()); + cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); // Access Token은 쿼리 파라미터로 프론트엔드에 전달 String targetUrl = UriComponentsBuilder.fromUriString(frontUrl + "/oauth/callback") diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java index 788cc99..13f5ce0 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -1,10 +1,17 @@ package com.teamEWSN.gitdeun.common.oauth.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; @@ -12,6 +19,7 @@ import java.util.Base64; import java.util.Map; +@Slf4j @Component @RequiredArgsConstructor public class GitHubApiHelper { @@ -24,6 +32,68 @@ public class GitHubApiHelper { @Value("${spring.security.oauth2.client.registration.github.client-secret}") private String clientSecret; + @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") + private String redirectUri; + + /** + * 인가 코드로 GitHub Access Token을 요청합니다. + * @param code GitHub에서 받은 인가 코드 + * @return Access Token 문자열 + */ + public String getAccessToken(String code) { + String tokenUri = "https://github.com/login/oauth/access_token"; + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + formData.add("code", code); + formData.add("redirect_uri", redirectUri); + + Map response = webClient.post() + .uri(tokenUri) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(formData) + .retrieve() + // 단순 map이 아닌 정확한 타입 정보를 런타임에도 잃어버리지 않도록 ParameterizedTypeReference를 사용 + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + if (response == null || response.get("access_token") == null) { + log.error("GitHub Access Token 발급 실패: {}", response); + throw new GlobalException(ErrorCode.OAUTH_PROCESSING_ERROR); + } + + return (String) response.get("access_token"); + } + + /** + * Access Token으로 GitHub 사용자 정보를 조회합니다. + * @param accessToken GitHub Access Token + * @return GitHub 사용자 정보를 담은 DTO + */ + public GitHubResponseDto getUserInfo(String accessToken) { + String userInfoUri = "https://api.github.com/user"; + + Map attributes = webClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + if (attributes == null) { + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } + + return new GitHubResponseDto(attributes); + } + + + /** + * GitHub OAuth 토큰을 해지합니다. + * @param accessToken 해지할 Access Token + * @return Mono + */ public Mono revokeToken(String accessToken) { String revokeUrl = "https://api.github.com/applications/" + clientId + "/token"; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java index 1620c28..02dd611 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java @@ -12,15 +12,15 @@ */ @Component public class AuthenticateUser { - public Long authenticateUserId() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + public Long authenticateUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) { - return 0L; // 인증되지 않은 사용자일 경우 0 반환 - } + if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) { + return 0L; // 인증되지 않은 사용자일 경우 0 반환 + } - // 인증된 사용자의 경우 userId 반환 - CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); - return userDetails.getId(); - } + // 인증된 사용자의 경우 userId 반환 + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return userDetails.getId(); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index 93628d7..5d06c4b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -53,19 +54,13 @@ public ResponseEntity refreshAccessToken( return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); } - /** - * 이미 로그인된 사용자의 계정에 추가로 소셜 계정을 연동 - * @param userDetails 현재 로그인된 사용자 정보 (JWT 기반) - * @param provider 연동할 소셜 플랫폼 - * @param code 연동할 계정의 인가 코드 - */ - @GetMapping("/connect/{provider}") - public ResponseEntity connectSocialAccount( - @AuthenticationPrincipal CustomUserDetails userDetails, - @PathVariable("provider") String provider, - @RequestParam String code) { + @GetMapping("/connect/github/callback") + public ResponseEntity connectGithubAccountCallback( + @RequestParam("code") String code, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + authService.connectGithubAccount(userDetails.getId(), code); - authService.connectSocialAccount(userDetails.getId(), provider, code); return ResponseEntity.ok().build(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index 94ed87f..d17f8f6 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -6,7 +6,13 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.*; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -26,12 +32,23 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final BlacklistService blacklistService; private final UserService userService; - private final RefreshTokenRepository refreshTokenRepository; private final CookieUtil cookieUtil; + private final GitHubApiHelper gitHubApiHelper; + private final UserRepository userRepository; + private final SocialConnectionRepository socialConnectionRepository; @Value("${jwt.refresh-expired}") private Long refreshTokenExpired; + @Value("${spring.security.oauth2.client.registration.github.client-id}") + private String githubClientId; + + @Value("${spring.security.oauth2.client.registration.github.client-secret}") + private String githubClientSecret; + + @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") + private String githubRedirectUri; + // 로그 아웃 @Transactional @@ -62,21 +79,6 @@ public JwtToken refreshTokens(String refreshToken) { return jwtTokenProvider.generateToken(authentication); } - // connectSocialAccount 로직은 CustomOAuth2UserService의 로직과 유사하게 - // 인가 코드로 토큰을 받고, 토큰으로 유저 정보를 받아와 SocialConnection을 생성/저장해야 합니다. - // 이 부분은 각 소셜 플랫폼의 API를 직접 호출해야 하므로 WebClient를 사용한 구현이 필요합니다. - @Transactional - public void connectSocialAccount(Long userId, String provider, String code) { - // TODO: WebClient를 사용하여 provider(github)의 - // 1. 인가 코드(code)로 Access Token 요청 - // 2. Access Token으로 사용자 정보 요청 - // 3. 받아온 정보로 SocialConnection 객체 생성 및 저장 - // 이 로직은 CustomOAuth2UserService의 로직을 참고하여 작성할 수 있습니다. - // WebClient 설정은 SecurityConfig에 Bean으로 등록 후 주입받아 사용합니다. - - System.out.printf("계정 연동 시도: userId=%d, provider=%s, code=%s\n", userId, provider, code); - } - // User 객체를 사용해 authentication 생성 private Authentication createAuthentication(User user) { CustomUserDetails customUserDetails = new CustomUserDetails( @@ -84,4 +86,50 @@ private Authentication createAuthentication(User user) { return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); } + + + // 구글 -> Github 계정 연동 + @Transactional + public void connectGithubAccount(Long userId, String code) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // GitHubApiHelper를 통해 Access Token 요청 + String accessToken = gitHubApiHelper.getAccessToken(code); + + // GitHubApiHelper를 통해 사용자 정보 요청 + GitHubResponseDto githubResponse = gitHubApiHelper.getUserInfo(accessToken); + + String providerId = githubResponse.getProviderId(); + OauthProvider provider = OauthProvider.GITHUB; + + // 이미 다른 계정에 연동되어 있는지 확인 + socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .ifPresent(connection -> { + if (!connection.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } + }); + + // 현재 사용자에 대한 중복 연동 확인 (이미 연동되어 있으면 추가 로직 없음) + boolean alreadyLinked = user.getSocialConnections().stream() + .anyMatch(conn -> conn.getProvider() == provider && conn.getProviderId().equals(providerId)); + + if (alreadyLinked) { + log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); + return; // 이미 연동되었으므로 여기서 종료 + } + + // 신규 소셜 연동 정보 생성 및 저장 + SocialConnection newConnection = SocialConnection.builder() + .user(user) + .provider(provider) + .providerId(providerId) + .accessToken(accessToken) + .refreshToken(null) + .build(); + + socialConnectionRepository.save(newConnection); + log.info("사용자(ID:{})에게 GitHub 계정(ProviderId:{}) 연동이 완료되었습니다.", userId, providerId); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7430f06..c7fd4e8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,7 +35,7 @@ spring: client-secret: ${GOOGLE_CLIENT_SECRET} scope: profile, email github: - scope: read:user, user:email, repo + scope: user:email, repo profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정 From 1034810cd9d215863423d198bff3a4424c525cf1 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 19 Jul 2025 16:24:11 +0900 Subject: [PATCH 22/60] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EB=B6=80=EB=B6=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/jwt/JwtAuthenticationFilter.java | 99 ++----------------- 1 file changed, 9 insertions(+), 90 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java index 576b4fa..ebf6aa6 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java @@ -1,8 +1,6 @@ package com.teamEWSN.gitdeun.common.jwt; import com.fasterxml.jackson.databind.ObjectMapper; -import com.teamEWSN.gitdeun.common.exception.ErrorCode; -import com.teamEWSN.gitdeun.common.exception.ErrorResponse; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -11,14 +9,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; +import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.Collection; @Slf4j @RequiredArgsConstructor @@ -28,104 +24,27 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final ObjectMapper objectMapper; private static final String BEARER = "Bearer"; - private static final String ADMIN_API_PREFIX = "/api/admin"; @Override protected void doFilterInternal( HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain ) throws ServletException, IOException { - String requestURI = request.getRequestURI(); - boolean isAdminRequest = requestURI.startsWith(ADMIN_API_PREFIX); String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); // access token이 있고, BEARER로 시작한다면 if (authHeader != null && authHeader.startsWith(BEARER)) { - String token = authHeader.substring(BEARER.length()).trim(); // trim 추가 - - try { - // 토큰 검증 - if (jwtTokenProvider.validateToken(token)) { - // 유효한 토큰: 유저 정보 가져옴 - Authentication authentication = jwtTokenProvider.getAuthentication(token); - - // 관리자 API 접근 시 추가 검증 - if (isAdminRequest && !hasAdminRole(authentication.getAuthorities())) { - log.warn("관리자 권한 없이 관리자 리소스에 접근 시도: {}", requestURI); - sendAccessDeniedResponse(response); - return; - } - - SecurityContextHolder.getContext().setAuthentication(authentication); - } else { - // 토큰이 유효하지 않은 경우 - 관리자 API는 차단, 일반 API는 진행 - if (isAdminRequest) { - log.warn("유효하지 않은 토큰으로 관리자 리소스 접근 시도: {}", requestURI); - sendUnauthorizedResponse(response); - return; - } - // 일반 API는 인증 없이도 접근 가능한 경우가 있으므로 계속 진행 - } - } catch (Exception e) { - // 토큰 처리 중 예외 발생 - log.error("JWT 토큰 처리 중 오류 발생 - URI: {}, Error: {}", requestURI, e.getMessage()); - - if (isAdminRequest) { - // 관리자 API는 예외 발생 시 차단 - sendUnauthorizedResponse(response); - return; - } - // 일반 API는 인증 실패로 처리하고 서비스 계속 진행 (SecurityContext 비워둠) - SecurityContextHolder.clearContext(); + String token = authHeader.substring(BEARER.length()); + // 토큰 검증 + if (jwtTokenProvider.validateToken(token)) { + // 유효한 토큰: 유저 정보 가져옴 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); } - } else if (isAdminRequest) { - // 관리자 API 접근 시 토큰 없으면 Unauthorized 응답 - log.warn("인증 없이 관리자 리소스에 접근 시도: {}", requestURI); - sendUnauthorizedResponse(response); - return; } filterChain.doFilter(request, response); } - - private boolean hasAdminRole(Collection authorities) { - return authorities.stream() - .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); - } - - // 접근 거부 응답 처리(403 - 권한 없음) - private void sendAccessDeniedResponse(HttpServletResponse response) throws IOException { - log.warn("403 Forbidden - 접근 거부됨"); - - ErrorCode errorCode = ErrorCode.ACCESS_DENIED; - ErrorResponse errorResponse = ErrorResponse.builder() - .code(errorCode.getCode()) - .message(errorCode.getMessage()) - .build(); - - response.setStatus(errorCode.getHttpStatus().value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding("UTF-8"); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); - - } - - // 인증 실패 응답 처리 (401 - 인증 실패) - private void sendUnauthorizedResponse(HttpServletResponse response) throws IOException { - log.warn("401 Unauthorized - 인증 실패"); - - ErrorCode errorCode = ErrorCode.NO_TOKEN; - ErrorResponse errorResponse = ErrorResponse.builder() - .code(errorCode.getCode()) - .message(errorCode.getMessage()) - .build(); - - response.setStatus(errorCode.getHttpStatus().value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding("UTF-8"); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); - - } } From 6fb90fb428faadfdfab49c74920b7ac481c98dfd Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sun, 20 Jul 2025 03:10:32 +0900 Subject: [PATCH 23/60] =?UTF-8?q?style:=20=EC=A3=BC=EC=84=9D=20=EB=B0=8F?= =?UTF-8?q?=20api=20=EA=B2=BD=EB=A1=9C=EB=AA=85=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EB=AF=B8=ED=95=84=EC=9A=94=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/teamEWSN/gitdeun/user/controller/AuthController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index 5d06c4b..adbd93a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -9,14 +9,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @Slf4j @RestController -@RequestMapping("/api/oauth") +@RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { @@ -54,6 +53,7 @@ public ResponseEntity refreshAccessToken( return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); } + // 깃허브 계정 연동 @GetMapping("/connect/github/callback") public ResponseEntity connectGithubAccountCallback( @RequestParam("code") String code, From b2470875cb3d88167a107287c42ab2a1be022ede Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Tue, 22 Jul 2025 01:14:09 +0900 Subject: [PATCH 24/60] =?UTF-8?q?fix:=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EA=B3=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20callback=20uri=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{entity => dto}/CustomOAuth2User.java | 2 +- .../common/oauth/dto/OAuth2UserDto.java | 16 ---- .../handler/CustomOAuth2SuccessHandler.java | 33 +++++---- .../service/CustomOAuth2UserService.java | 32 ++++---- .../common/oauth/service/GitHubApiHelper.java | 4 +- .../oauth/service/OAuthStateService.java | 29 ++++++++ .../user/controller/AuthController.java | 47 +++++++++--- .../gitdeun/user/service/AuthService.java | 74 ++++++++++++------- src/main/resources/application-dev.yml | 4 +- src/main/resources/application-prod.yml | 7 +- 10 files changed, 159 insertions(+), 89 deletions(-) rename src/main/java/com/teamEWSN/gitdeun/common/oauth/{entity => dto}/CustomOAuth2User.java (95%) delete mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java similarity index 95% rename from src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java rename to src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java index a79f1e1..ea70964 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.common.oauth.entity; +package com.teamEWSN.gitdeun.common.oauth.dto; import com.teamEWSN.gitdeun.user.entity.Role; import lombok.Getter; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java deleted file mode 100644 index f0cf9ad..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.teamEWSN.gitdeun.common.oauth.dto; - -import lombok.Builder; -import lombok.Getter; -import lombok.ToString; - -@ToString -@Getter -@Builder -public class OAuth2UserDto { - private String nickname; - private String name; - private String email; - private String role; - private String profileImage; -} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java index 03dc0aa..d96969f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java @@ -3,16 +3,18 @@ import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.JwtToken; import com.teamEWSN.gitdeun.common.jwt.JwtTokenProvider; -import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; +import com.teamEWSN.gitdeun.user.service.AuthService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; @@ -22,6 +24,8 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final JwtTokenProvider jwtTokenProvider; + private final OAuthStateService oAuthStateService; + private final AuthService authService; private final CookieUtil cookieUtil; @Value("${app.front-url}") @@ -29,19 +33,22 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - - // 우리 서비스의 JWT 생성 + OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication; + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String state = request.getParameter("state"); + + String purpose = oAuthStateService.consumeState(state); // "connect:42" 또는 null + if (purpose != null && purpose.startsWith("connect:")) { + Long userId = Long.parseLong(purpose.split(":")[1]); + authService.connectGithubAccount(oAuth2User, userId); + response.sendRedirect(frontUrl + "/oauth/callback#connected=true"); + return; + } + + // 일반 로그인 흐름 JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); - log.info("JWT가 발급되었습니다. Access Token: {}", jwtToken.getAccessToken()); - - // Refresh Token은 HttpOnly 쿠키에 저장 cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); - - // Access Token은 쿼리 파라미터로 프론트엔드에 전달 - String targetUrl = UriComponentsBuilder.fromUriString(frontUrl + "/oauth/callback") - .queryParam("accessToken", jwtToken.getAccessToken()) - .build().toUriString(); - + String targetUrl = frontUrl + "/oauth/callback#accessToken=" + jwtToken.getAccessToken(); getRedirectStrategy().sendRedirect(request, response, targetUrl); } } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index cf9c64e..4bcd855 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -11,7 +11,7 @@ import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; import com.teamEWSN.gitdeun.user.repository.UserRepository; -import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import com.teamEWSN.gitdeun.common.oauth.dto.CustomOAuth2User; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,6 +21,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import java.util.Optional; import java.util.UUID; @@ -44,12 +45,12 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); } - User user = processUserInTransaction(oAuth2User, userRequest); + User user = processUser(oAuth2User, userRequest); return new CustomOAuth2User(user.getId(), user.getRole()); } // @Transactional - public User processUserInTransaction(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + public User processUser(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { OAuth2ResponseDto oAuth2ResponseDto = getOAuth2ResponseDto(oAuth2User, userRequest); // 이메일 정보가 없는 경우 예외 처리 (GitHub 등) @@ -70,19 +71,18 @@ public User processUserInTransaction(OAuth2User oAuth2User, OAuth2UserRequest us }) .orElseGet(() -> { // 다른 사용자가 이미 해당 이메일을 사용 중인지 확인 - userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) - .ifPresent(existingUser -> { - // 이메일은 같지만, 소셜 연동 정보가 없는 경우 -> 계정 연동 - log.info("기존 회원 계정에 소셜 계정을 연동합니다: {}", provider); - connectSocialAccount(existingUser, provider, providerId, accessToken, refreshToken); - }); - // 위에서 연동했거나, 완전 신규 유저인 경우를 처리 - // 다시 이메일로 조회하여 최종 유저를 반환하거나 새로 생성 - return userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) - .orElseGet(() -> { - log.info("신규 회원 및 소셜 계정을 생성합니다: {}", provider); - return createNewUser(oAuth2ResponseDto, provider, providerId, accessToken, refreshToken); - }); + Optional existingUser = userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()); + if (existingUser.isPresent()) { + // 이메일은 같지만, 소셜 연동 정보가 없는 경우 -> 계정 연동 + log.info("기존 회원 계정에 소셜 계정을 연동합니다: {}", provider); + + connectSocialAccount(existingUser.get(), provider, providerId, accessToken, refreshToken); + return existingUser.get(); + } else { // 위에서 연동했거나, 완전 신규 유저인 경우를 처리 + // 다시 이메일로 조회하여 최종 유저를 반환하거나 새로 생성 + log.info("신규 회원 및 소셜 계정을 생성합니다: {}", provider); + return createNewUser(oAuth2ResponseDto, provider, providerId, accessToken, refreshToken); + } }); } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java index 13f5ce0..644421e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -32,15 +32,13 @@ public class GitHubApiHelper { @Value("${spring.security.oauth2.client.registration.github.client-secret}") private String clientSecret; - @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") - private String redirectUri; /** * 인가 코드로 GitHub Access Token을 요청합니다. * @param code GitHub에서 받은 인가 코드 * @return Access Token 문자열 */ - public String getAccessToken(String code) { + public String getAccessToken(String code, String redirectUri) { String tokenUri = "https://github.com/login/oauth/access_token"; MultiValueMap formData = new LinkedMultiValueMap<>(); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java new file mode 100644 index 0000000..cf776bb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java @@ -0,0 +1,29 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OAuthStateService { + + private final RedisTemplate redisTemplate; + private static final Duration EXPIRATION = Duration.ofMinutes(3); + + public String createState(String purpose) { + String state = UUID.randomUUID().toString(); + redisTemplate.opsForValue().set("oauth:state:" + state, purpose, EXPIRATION); + return state; + } + + public String consumeState(String state) { + String key = "oauth:state:" + state; + String purpose = redisTemplate.opsForValue().get(key); + redisTemplate.delete(key); + return purpose; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index adbd93a..5f175b1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -3,6 +3,7 @@ import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; import com.teamEWSN.gitdeun.user.dto.UserTokenResponseDto; import com.teamEWSN.gitdeun.user.service.AuthService; import jakarta.servlet.http.HttpServletResponse; @@ -13,6 +14,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @Slf4j @RestController @RequestMapping("/api/auth") @@ -22,9 +25,18 @@ public class AuthController { @Value("${jwt.refresh-expired}") private Long refreshTokenExpired; + private final OAuthStateService oAuthStateService; private final AuthService authService; private final CookieUtil cookieUtil; + @Value("${app.front-url}") + private String frontUrl; + + @GetMapping("/connect/github/state") + public ResponseEntity> generateStateForGithubConnect(@AuthenticationPrincipal CustomUserDetails user) { + String state = oAuthStateService.createState("connect:" + user.getId()); + return ResponseEntity.ok(Map.of("state", state)); + } // 로그아웃 API @PostMapping("/logout") @@ -53,15 +65,30 @@ public ResponseEntity refreshAccessToken( return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); } - // 깃허브 계정 연동 - @GetMapping("/connect/github/callback") - public ResponseEntity connectGithubAccountCallback( - @RequestParam("code") String code, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - authService.connectGithubAccount(userDetails.getId(), code); - - return ResponseEntity.ok().build(); - } +// /** +// * GitHub의 모든 OAuth 콜백을 처리하는 단일 엔드포인트 +// * @param code GitHub에서 제공하는 Authorization Code +// * @param userDetails 현재 로그인된 사용자 정보. 비로그인 상태면 null. +// * @return 로그인 또는 계정 연동 흐름에 따라 적절한 경로로 포워딩 또는 리디렉션 +// */ +// @GetMapping("/github/callback") +// public ResponseEntity githubCallback( +// @RequestParam("code") String code, +// @AuthenticationPrincipal CustomUserDetails userDetails, +// HttpServletResponse response // 쿠키 설정을 위해 필요 +// ) { +// +// if (userDetails != null) { +// // "계정 연동" 흐름 +// authService.connectGithubAccount(userDetails.getId(), code); +// // 성공했다는 응답 전달 +// return ResponseEntity.ok().body(Map.of("status", "success", "message", "계정 연동 성공!")); +// +// } else { +// // "최초 로그인" 흐름 +// GithubLoginResponseDto loginResponse = authService.loginWithGithub(code, response); +// return ResponseEntity.ok(loginResponse); +// } +// } } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index d17f8f6..7fb7d60 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -6,10 +6,10 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.*; -import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; @@ -19,9 +19,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; + @Slf4j @Service @@ -31,6 +34,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; private final BlacklistService blacklistService; + private final CustomOAuth2UserService customOAuth2UserService; private final UserService userService; private final CookieUtil cookieUtil; private final GitHubApiHelper gitHubApiHelper; @@ -49,6 +53,8 @@ public class AuthService { @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") private String githubRedirectUri; + @Value("${app.oauth.github.connect-redirect-uri}") + private String githubConnectRedirectUri; // 로그 아웃 @Transactional @@ -89,47 +95,63 @@ private Authentication createAuthentication(User user) { // 구글 -> Github 계정 연동 - @Transactional - public void connectGithubAccount(Long userId, String code) { + public void connectGithubAccount(OAuth2User githubUser, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - // GitHubApiHelper를 통해 Access Token 요청 - String accessToken = gitHubApiHelper.getAccessToken(code); - - // GitHubApiHelper를 통해 사용자 정보 요청 - GitHubResponseDto githubResponse = gitHubApiHelper.getUserInfo(accessToken); + String providerId = Objects.requireNonNull(githubUser.getAttribute("id")).toString(); + String accessToken = githubUser.getAttribute("access_token"); + String refreshToken = githubUser.getAttribute("refresh_token"); - String providerId = githubResponse.getProviderId(); - OauthProvider provider = OauthProvider.GITHUB; - - // 이미 다른 계정에 연동되어 있는지 확인 - socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + // 이미 다른 계정에 연동되어 있는지, 동일 사용자에 의해 이미 연동되는지 확인 + socialConnectionRepository.findByProviderAndProviderId(OauthProvider.GITHUB, providerId) .ifPresent(connection -> { if (!connection.getUser().getId().equals(userId)) { throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } else { + log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); + return; } }); - // 현재 사용자에 대한 중복 연동 확인 (이미 연동되어 있으면 추가 로직 없음) - boolean alreadyLinked = user.getSocialConnections().stream() - .anyMatch(conn -> conn.getProvider() == provider && conn.getProviderId().equals(providerId)); - - if (alreadyLinked) { - log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); - return; // 이미 연동되었으므로 여기서 종료 - } // 신규 소셜 연동 정보 생성 및 저장 - SocialConnection newConnection = SocialConnection.builder() + SocialConnection connection = SocialConnection.builder() .user(user) - .provider(provider) + .provider(OauthProvider.GITHUB) .providerId(providerId) .accessToken(accessToken) - .refreshToken(null) + .refreshToken(refreshToken) .build(); - socialConnectionRepository.save(newConnection); - log.info("사용자(ID:{})에게 GitHub 계정(ProviderId:{}) 연동이 완료되었습니다.", userId, providerId); + socialConnectionRepository.save(connection); } +// +// /** +// * GitHub 콜백 코드를 받아 로그인 또는 회원가입을 처리하고 JWT를 발급하는 메서드 +// * @param code GitHub에서 받은 Authorization Code +// * @param response HttpServletResponse (쿠키 설정을 위해) +// * @return 생성된 JWT와 사용자 정보를 담은 DTO +// */ +// @Transactional +// public GithubLoginResponseDto loginWithGithub(String code, HttpServletResponse response) { +// // 1. 코드로 GitHub Access Token 받기 +// String githubAccessToken = gitHubApiHelper.getAccessToken(code, "YOUR_SINGLE_CALLBACK_URL"); // 실제 콜백 URL 필요 +// +// // Access Token으로 GitHub 사용자 정보 받기 +// OAuth2UserRequest userRequest = createOAuth2UserRequest(githubAccessToken); +// OAuth2User oAuth2User = super.loadUser(userRequest); // DefaultOAuth2UserService의 메서드 활용 +// +// // CustomOAuth2UserService의 핵심 로직을 호출하여 User 엔티티 처리 +// User user = customOAuth2UserService.processUserInTransaction(oAuth2User, userRequest); +// +// // 내부 JWT 생성 +// JwtToken jwtToken = jwtTokenProvider.generateToken(createAuthentication(user)); +// +// // HttpOnly 쿠키에 Refresh Token 저장 +// cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); +// +// // 프론트엔드에 전달할 DTO 생성 +// return new GithubLoginResponseDto(jwtToken.getAccessToken(), user.getNickname(), user.getProfileImage()); +// } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 69e048c..5f65fb5 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -9,11 +9,11 @@ spring: client: registration: google: - redirect-uri: http://localhost:8080/login/oauth2/code/google + redirect-uri: http://localhost:8080/login/oauth2/code/google # /oauth2/authorization/google 로그인 시작 URL github: client-id: ${GITHUB_DEV_CLIENT_ID} client-secret: ${GITHUB_DEV_CLIENT_SECRET} - redirect-uri: http://localhost:8080/login/oauth2/code/github + redirect-uri: http://localhost:8080/api/auth/github/callback # provider: # google: # authorization-uri: https://accounts.google.com/o/oauth2/v2/auth diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a8081ce..406862d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -17,7 +17,7 @@ spring: github: client-id: ${GITHUB_PROD_CLIENT_ID} client-secret: ${GITHUB_PROD_CLIENT_SECRET} - redirect-uri: https://api.gitdeun.site/login/oauth2/code/github + redirect-uri: https://api.gitdeun.site/api/auth/github/callback jwt: @@ -28,4 +28,7 @@ app: front-url: https://gitdeun.site cookie: secure: true - same-site: None \ No newline at end of file + same-site: None + oauth: + github: + connect-redirect-uri: https://api.gitdeun.site/api/auth/connect/github \ No newline at end of file From 93e5fbcbf675b8c3337b5417c852a4c5c5637ac7 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 24 Jul 2025 10:02:59 +0900 Subject: [PATCH 25/60] =?UTF-8?q?refactor:=20baseEntity=20->=20created,=20?= =?UTF-8?q?audited=20entity=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/oauth/entity/SocialConnection.java | 4 ++-- .../gitdeun/common/util/AuditedEntity.java | 24 +++++++++++++++++++ .../{BaseEntity.java => CreatedEntity.java} | 7 +----- .../teamEWSN/gitdeun/user/entity/User.java | 4 ++-- 4 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java rename src/main/java/com/teamEWSN/gitdeun/common/util/{BaseEntity.java => CreatedEntity.java} (80%) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java index 9b1ce2f..ccbb3da 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.common.oauth.entity; import com.teamEWSN.gitdeun.common.converter.CryptoConverter; -import com.teamEWSN.gitdeun.common.util.BaseEntity; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; import lombok.*; @@ -10,7 +10,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "social_connection") -public class SocialConnection extends BaseEntity { +public class SocialConnection extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java new file mode 100644 index 0000000..3b04b77 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java @@ -0,0 +1,24 @@ +package com.teamEWSN.gitdeun.common.util; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@MappedSuperclass +public class AuditedEntity extends CreatedEntity { + + @LastModifiedDate + @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime updatedAt; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java similarity index 80% rename from src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java rename to src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java index e2dcb5b..dc5a3f7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java @@ -15,14 +15,9 @@ @Getter @Setter @MappedSuperclass -public class BaseEntity { +public class CreatedEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)") private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)") - private LocalDateTime updatedAt; - } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 5e33ea3..0d0c532 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.user.entity; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; -import com.teamEWSN.gitdeun.common.util.BaseEntity; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; import jakarta.persistence.*; import lombok.*; @@ -13,7 +13,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "users") -public class User extends BaseEntity { +public class User extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; From df2d97bec3db294cad1193c0dbfd5edfe7bf4000 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 25 Jul 2025 05:31:35 +0900 Subject: [PATCH 26/60] =?UTF-8?q?feat:=20OAuth=20create,=20connect=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20state?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=9C=20=EA=B5=AC=EB=B6=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/exception/ErrorCode.java | 2 +- .../common/oauth/dto/GitHubEmailDto.java | 17 +++ .../oauth/record/GoogleTokenResponse.java | 12 ++ .../service/CustomOAuth2UserService.java | 84 ++++++----- .../common/oauth/service/GitHubApiHelper.java | 56 ++------ .../common/oauth/service/GoogleApiHelper.java | 85 ++++++++++++ .../service/SocialTokenRefreshService.java | 131 ++++++------------ .../user/controller/AuthController.java | 1 + 8 files changed, 217 insertions(+), 171 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 1f432bd..7aee070 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -31,7 +31,7 @@ public enum ErrorCode { OAUTH_COMMUNICATION_FAILED(HttpStatus.BAD_GATEWAY, "OAUTH-004", "소셜 플랫폼과의 통신에 실패했습니다."), SOCIAL_TOKEN_REFRESH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-005", "소셜 플랫폼의 토큰 갱신에 실패했습니다."), SOCIAL_ACCOUNT_CONNECT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-006", "소셜 계정 연동에 실패했습니다."), - GITHUB_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "GitHub 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), + SOCIAL_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "리프레시 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), // S3 파일 관련 // Client Errors (4xx) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java new file mode 100644 index 0000000..05176c5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GitHubEmailDto { + + private String email; + private boolean primary; + private boolean verified; + private String visibility; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java new file mode 100644 index 0000000..dceba76 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.common.oauth.record; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("scope") String scope, + @JsonProperty("id_token") String idToken +) {} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index 4bcd855..d9d2310 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -2,6 +2,7 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; import com.teamEWSN.gitdeun.common.oauth.dto.provider.GoogleResponseDto; import com.teamEWSN.gitdeun.common.oauth.dto.provider.OAuth2ResponseDto; @@ -21,7 +22,8 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import java.util.Optional; +import java.util.List; +import java.util.Map; import java.util.UUID; @@ -30,6 +32,8 @@ @RequiredArgsConstructor public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final GitHubApiHelper gitHubApiHelper; + private final SocialTokenRefreshService socialTokenRefreshService; private final UserRepository userRepository; private final SocialConnectionRepository socialConnectionRepository; @@ -51,54 +55,62 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic // @Transactional public User processUser(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { - OAuth2ResponseDto oAuth2ResponseDto = getOAuth2ResponseDto(oAuth2User, userRequest); - - // 이메일 정보가 없는 경우 예외 처리 (GitHub 등) - if (oAuth2ResponseDto.getEmail() == null) { - throw new GlobalException(ErrorCode.EMAIL_NOT_PROVIDED); - } - - OauthProvider provider = OauthProvider.valueOf(oAuth2ResponseDto.getProvider().toUpperCase()); - String providerId = oAuth2ResponseDto.getProviderId(); - String accessToken = userRequest.getAccessToken().getTokenValue(); + OAuth2ResponseDto dto = getOAuth2ResponseDto(oAuth2User, userRequest); + OauthProvider provider = OauthProvider.valueOf(dto.getProvider().toUpperCase()); + String providerId = dto.getProviderId(); + String accessToken = userRequest.getAccessToken().getTokenValue(); String refreshToken = (String) userRequest.getAdditionalParameters().get("refresh_token"); + /* ② 이미 연결된 계정 → 토큰 갱신 로직 추상화 */ return socialConnectionRepository.findByProviderAndProviderId(provider, providerId) - .map(connection -> { - log.info("기존 소셜 계정 정보를 업데이트합니다: {}", provider); - connection.updateTokens(accessToken, refreshToken); - return connection.getUser(); + .map(conn -> { + // provider 별 refresh 정책 + socialTokenRefreshService.refreshSocialToken(conn, accessToken, refreshToken); + return conn.getUser(); }) - .orElseGet(() -> { - // 다른 사용자가 이미 해당 이메일을 사용 중인지 확인 - Optional existingUser = userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()); - if (existingUser.isPresent()) { - // 이메일은 같지만, 소셜 연동 정보가 없는 경우 -> 계정 연동 - log.info("기존 회원 계정에 소셜 계정을 연동합니다: {}", provider); - - connectSocialAccount(existingUser.get(), provider, providerId, accessToken, refreshToken); - return existingUser.get(); - } else { // 위에서 연동했거나, 완전 신규 유저인 경우를 처리 - // 다시 이메일로 조회하여 최종 유저를 반환하거나 새로 생성 - log.info("신규 회원 및 소셜 계정을 생성합니다: {}", provider); - return createNewUser(oAuth2ResponseDto, provider, providerId, accessToken, refreshToken); - } - }); + .orElseGet(() -> createOrConnect(dto, provider, providerId, accessToken, refreshToken)); } - private static OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + // OAuth2 공급자로부터 받은 사용자 정보를 기반으로 OAuth2ResponseDto를 생성(인스턴스 메서드) + private OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { String registrationId = userRequest.getClientRegistration().getRegistrationId(); + Map attr = oAuth2User.getAttributes(); - OAuth2ResponseDto oAuth2ResponseDto; if (registrationId.equalsIgnoreCase("google")) { - oAuth2ResponseDto = new GoogleResponseDto(oAuth2User.getAttributes()); - } else if (registrationId.equalsIgnoreCase("github")) { - oAuth2ResponseDto = new GitHubResponseDto(oAuth2User.getAttributes()); + return new GoogleResponseDto(attr); + } + if (registrationId.equalsIgnoreCase("github")) { + /* ① 기본 프로필에 e-mail 없으면 /user/emails 호출 */ + if (attr.get("email") == null) { + // accessToken 으로 GitHub 보조 API 호출 + List emails = + gitHubApiHelper.getPrimaryEmails(userRequest.getAccessToken().getTokenValue()); + attr.put("email", + emails.stream().filter(GitHubEmailDto::isPrimary) + .findFirst().map(GitHubEmailDto::getEmail).orElse(null)); + } + return new GitHubResponseDto(attr); } else { // 지원하지 않는 소셜 로그인 제공자 throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER); } - return oAuth2ResponseDto; + } + + /** + * 사용자가 존재하면 계정을 연결하고, 존재하지 않으면 새로 생성합니다. + */ + private User createOrConnect(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + // 이메일로 기존 사용자를 찾습니다. + return userRepository.findByEmailAndDeletedAtIsNull(response.getEmail()) + .map(user -> { + // 사용자가 존재하면, 새 소셜 계정을 연결 + connectSocialAccount(user, provider, providerId, accessToken, refreshToken); + return user; + }) + .orElseGet(() -> { + // 사용자가 존재하지 않으면, 새 사용자를 생성 + return createNewUser(response, provider, providerId, accessToken, refreshToken); + }); } private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java index 644421e..fffd828 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -2,6 +2,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.List; import java.util.Map; @Slf4j @@ -34,56 +36,26 @@ public class GitHubApiHelper { /** - * 인가 코드로 GitHub Access Token을 요청합니다. - * @param code GitHub에서 받은 인가 코드 - * @return Access Token 문자열 - */ - public String getAccessToken(String code, String redirectUri) { - String tokenUri = "https://github.com/login/oauth/access_token"; - - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", clientId); - formData.add("client_secret", clientSecret); - formData.add("code", code); - formData.add("redirect_uri", redirectUri); - - Map response = webClient.post() - .uri(tokenUri) - .accept(MediaType.APPLICATION_JSON) - .bodyValue(formData) - .retrieve() - // 단순 map이 아닌 정확한 타입 정보를 런타임에도 잃어버리지 않도록 ParameterizedTypeReference를 사용 - .bodyToMono(new ParameterizedTypeReference>() {}) - .block(); - - if (response == null || response.get("access_token") == null) { - log.error("GitHub Access Token 발급 실패: {}", response); - throw new GlobalException(ErrorCode.OAUTH_PROCESSING_ERROR); - } - - return (String) response.get("access_token"); - } - - /** - * Access Token으로 GitHub 사용자 정보를 조회합니다. + * Access Token으로 사용자의 이메일 목록을 조회합니다. + * GitHub 기본 사용자 정보에 이메일이 포함되지 않은 경우 사용됩니다. * @param accessToken GitHub Access Token - * @return GitHub 사용자 정보를 담은 DTO + * @return 이메일 정보 DTO 리스트 */ - public GitHubResponseDto getUserInfo(String accessToken) { - String userInfoUri = "https://api.github.com/user"; + public List getPrimaryEmails(String accessToken) { + String emailsUri = "https://api.github.com/user/emails"; - Map attributes = webClient.get() - .uri(userInfoUri) - .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) + List emails = webClient.get() + .uri(emailsUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() - .bodyToMono(new ParameterizedTypeReference>() {}) + .bodyToMono(new ParameterizedTypeReference>() {}) .block(); - if (attributes == null) { + if (emails == null || emails.isEmpty()) { + log.error("GitHub 이메일 정보를 가져올 수 없습니다."); throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); } - - return new GitHubResponseDto(attributes); + return emails; } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java index cdfdb1d..e9514d3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -1,16 +1,101 @@ package com.teamEWSN.gitdeun.common.oauth.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; +import java.util.Optional; + +@Slf4j @Component @RequiredArgsConstructor public class GoogleApiHelper { private final WebClient webClient; + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String googleClientId; + + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String googleClientSecret; + + + /** + * 토큰이 만료되었는지 확인 + * @param accessToken 확인할 액세스 토큰 + * @return 만료 여부 (true: 만료됨, false: 유효함) + */ + protected boolean isExpired(String accessToken) { + // String validateUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo"; + try { + // 토큰 정보 요청 + webClient.get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("www.googleapis.com") + .path("/oauth2/v1/tokeninfo") + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + // 응답이 있으면 토큰이 유효함 + return false; // 만료되지 않음 + } catch (WebClientResponseException e) { + // 401, 400 등의 에러는 토큰이 만료되었거나 유효하지 않음 + log.debug("토큰 검증 오류: {}", e.getMessage()); + return true; // 만료됨 + } catch (Exception e) { + // 기타 예외 + log.error("토큰 검증 중 예상치 못한 오류: {}", e.getMessage()); + return true; // 안전하게 만료로 취급 + } + } + + /** + * 리프레시 토큰으로 새 액세스 토큰 요청 + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 응답 객체 + */ + protected GoogleTokenResponse refreshToken(String refreshToken) { + String tokenUrl = "https://oauth2.googleapis.com/token"; + + // 요청 바디 구성 + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", googleClientId); + formData.add("client_secret", googleClientSecret); + formData.add("refresh_token", refreshToken); + formData.add("grant_type", "refresh_token"); + + try { + // 토큰 갱신 요청 + return webClient.post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .bodyToMono(GoogleTokenResponse.class) + .block(); + } catch (Exception e) { + log.error("Google 토큰 갱신 실패: {}", e.getMessage()); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_FAILED); + } + } + + public Mono revokeToken(String accessToken) { String revokeUrl = "https://accounts.google.com/o/oauth2/revoke"; return webClient.post() diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java index d026f47..2d2a0f5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -1,34 +1,34 @@ package com.teamEWSN.gitdeun.common.oauth.service; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.Optional; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.reactive.function.BodyInserters; - // 레포 및 마인드맵 호출 시 소셜로그인 토큰 갱신 호출 @Slf4j @Service +@Transactional @RequiredArgsConstructor public class SocialTokenRefreshService { private final SocialConnectionRepository socialConnectionRepository; private final WebClient webClient; - private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper' + private final GitHubApiHelper gitHubApiHelper; + private final GoogleApiHelper googleApiHelper; @Value("${spring.security.oauth2.client.registration.google.client-id}") private String googleClientId; @@ -37,110 +37,57 @@ public class SocialTokenRefreshService { private String googleClientSecret; - // oauth 토큰 갱신 + // 기존 refreshToken 기반 갱신(주기적/자동 갱신) public void refreshSocialToken(Long userId, OauthProvider provider) { SocialConnection connection = socialConnectionRepository.findByUserIdAndProvider(userId, provider) .orElseThrow(() -> new GlobalException(ErrorCode.SOCIAL_CONNECTION_NOT_FOUND)); switch (provider) { - case GOOGLE -> refreshGoogleToken(connection); + case GOOGLE -> refreshGoogle(connection, Optional.empty(), Optional.empty()); case GITHUB -> { log.warn("GitHub는 토큰 갱신을 지원하지 않습니다. 재인증이 필요합니다."); - throw new GlobalException(ErrorCode.GITHUB_TOKEN_REFRESH_NOT_SUPPORTED); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_NOT_SUPPORTED); } } + + // 갱신 후 저장 명시 + socialConnectionRepository.save(connection); } - private void refreshGoogleToken(SocialConnection connection) { - if (connection.getRefreshToken() == null) { - throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + // oauth 새로운 토큰 제공 시 갱신(로그인 콜백) + public void refreshSocialToken(SocialConnection conn, + String latestAccess, String latestRefresh) { + switch (conn.getProvider()) { + case GOOGLE -> refreshGoogle(conn, Optional.ofNullable(latestAccess), + Optional.ofNullable(latestRefresh)); + // GitHub은 refresh 불가 + case GITHUB -> conn.updateTokens(latestAccess, null); // accessToken만 교체 } - try { - // Google Token 갱신 API 호출 - String tokenUrl = "https://oauth2.googleapis.com/token"; - - - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", googleClientId); - formData.add("client_secret", googleClientSecret); - formData.add("refresh_token", connection.getRefreshToken()); - formData.add("grant_type", "refresh_token"); - - - String response = webClient.post() - .uri(tokenUrl) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .bodyValue(BodyInserters.fromFormData(formData)) - .retrieve() - .bodyToMono(String.class) - .block(); // 결과를 동기적으로 기다림 - - JsonNode tokenNode = objectMapper.readTree(response); - - String newAccessToken = tokenNode.get("access_token").asText(); - // 구글은 리프레시 토큰을 갱신하면 기존 리프레시 토큰을 다시 주지 않는 경우가 대부분 - String newRefreshToken = tokenNode.has("refresh_token") ? - tokenNode.get("refresh_token").asText() : connection.getRefreshToken(); - - connection.updateTokens(newAccessToken, newRefreshToken); - socialConnectionRepository.save(connection); + socialConnectionRepository.save(conn); + } - log.info("Google 토큰 갱신 완료: userId={}", connection.getUser().getId()); - } catch (Exception e) { - log.error("Google 토큰 갱신 실패: userId={}, error={}", - connection.getUser().getId(), e.getMessage()); - throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_FAILED); + private void refreshGoogle(SocialConnection conn, + Optional latestAccessOpt, + Optional latestRefreshOpt) { + // 1. latestAccess가 주어지고 유효하면 교체 + if (latestAccessOpt.isPresent() && !googleApiHelper.isExpired(latestAccessOpt.get())) { + String newRefresh = latestRefreshOpt.orElse(conn.getRefreshToken()); + conn.updateTokens(latestAccessOpt.get(), newRefresh); + return; } - } - /** - * 토큰 유효성 검증 - */ - public boolean isTokenValid(String accessToken, OauthProvider provider) { - try { - return switch (provider) { - case GOOGLE -> validateGoogleToken(accessToken); - case GITHUB -> validateGitHubToken(accessToken); - }; - } catch (Exception e) { - log.error("토큰 유효성 검증 실패: provider={}, error={}", provider, e.getMessage()); - return false; + // 2. refreshToken 기반 재발급 (latestRefresh가 있으면 그것 사용, 없으면 기존) + String refreshToUse = latestRefreshOpt.orElse(conn.getRefreshToken()); + if (refreshToUse == null) { + throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); } - } - private boolean validateGoogleToken(String accessToken) { - String validateUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo"; - - try { - webClient.get() - .uri(uriBuilder -> uriBuilder - .path(validateUrl) - .queryParam("access_token", accessToken) - .build()) - .retrieve() - .bodyToMono(String.class) - .block(); - return true; - } catch (Exception e) { - return false; - } - } + GoogleTokenResponse res = googleApiHelper.refreshToken(refreshToUse); - private boolean validateGitHubToken(String accessToken) { - String validateUrl = "https://api.github.com/user"; - - try { - webClient.get() - .uri(validateUrl) - .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) - .retrieve() - .bodyToMono(String.class) - .block(); - return true; - } catch (Exception e) { - return false; - } + String newRefresh = (res.refreshToken() != null) ? res.refreshToken() : conn.getRefreshToken(); + conn.updateTokens(res.accessToken(), newRefresh); } + } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index 5f175b1..acc86dd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -32,6 +32,7 @@ public class AuthController { @Value("${app.front-url}") private String frontUrl; + // 깃허브 연동 흐름 구분 조회 @GetMapping("/connect/github/state") public ResponseEntity> generateStateForGithubConnect(@AuthenticationPrincipal CustomUserDetails user) { String state = oAuthStateService.createState("connect:" + user.getId()); From 679d47d01242dff3cac38d01f8ac4a9eb3e7f796 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 25 Jul 2025 07:19:21 +0900 Subject: [PATCH 27/60] =?UTF-8?q?fix:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/user/service/AuthService.java | 34 +------------------ src/main/resources/application-prod.yml | 5 +-- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index 7fb7d60..d9a4009 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -34,10 +34,8 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; private final BlacklistService blacklistService; - private final CustomOAuth2UserService customOAuth2UserService; private final UserService userService; private final CookieUtil cookieUtil; - private final GitHubApiHelper gitHubApiHelper; private final UserRepository userRepository; private final SocialConnectionRepository socialConnectionRepository; @@ -53,9 +51,6 @@ public class AuthService { @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") private String githubRedirectUri; - @Value("${app.oauth.github.connect-redirect-uri}") - private String githubConnectRedirectUri; - // 로그 아웃 @Transactional public void logout(String accessToken, String refreshToken, HttpServletResponse response) { @@ -126,32 +121,5 @@ public void connectGithubAccount(OAuth2User githubUser, Long userId) { socialConnectionRepository.save(connection); } -// -// /** -// * GitHub 콜백 코드를 받아 로그인 또는 회원가입을 처리하고 JWT를 발급하는 메서드 -// * @param code GitHub에서 받은 Authorization Code -// * @param response HttpServletResponse (쿠키 설정을 위해) -// * @return 생성된 JWT와 사용자 정보를 담은 DTO -// */ -// @Transactional -// public GithubLoginResponseDto loginWithGithub(String code, HttpServletResponse response) { -// // 1. 코드로 GitHub Access Token 받기 -// String githubAccessToken = gitHubApiHelper.getAccessToken(code, "YOUR_SINGLE_CALLBACK_URL"); // 실제 콜백 URL 필요 -// -// // Access Token으로 GitHub 사용자 정보 받기 -// OAuth2UserRequest userRequest = createOAuth2UserRequest(githubAccessToken); -// OAuth2User oAuth2User = super.loadUser(userRequest); // DefaultOAuth2UserService의 메서드 활용 -// -// // CustomOAuth2UserService의 핵심 로직을 호출하여 User 엔티티 처리 -// User user = customOAuth2UserService.processUserInTransaction(oAuth2User, userRequest); -// -// // 내부 JWT 생성 -// JwtToken jwtToken = jwtTokenProvider.generateToken(createAuthentication(user)); -// -// // HttpOnly 쿠키에 Refresh Token 저장 -// cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); -// -// // 프론트엔드에 전달할 DTO 생성 -// return new GithubLoginResponseDto(jwtToken.getAccessToken(), user.getNickname(), user.getProfileImage()); -// } + } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 406862d..fbd1c5f 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -28,7 +28,4 @@ app: front-url: https://gitdeun.site cookie: secure: true - same-site: None - oauth: - github: - connect-redirect-uri: https://api.gitdeun.site/api/auth/connect/github \ No newline at end of file + same-site: None \ No newline at end of file From 5ed7a87e951f20649f332a7bc3fd4be393f3e20c Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 25 Jul 2025 07:19:45 +0900 Subject: [PATCH 28/60] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EC=9D=98=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/exception/ErrorCode.java | 1 + .../controller/UserSettingController.java | 49 +++++++++++++ .../user/dto/UserSettingResponseDto.java | 17 +++++ .../user/dto/UserSettingUpdateRequestDto.java | 20 ++++++ .../gitdeun/user/entity/UserSetting.java | 72 +++++++++++++++++++ .../repository/UserSettingRepository.java | 14 ++++ .../user/service/UserSettingService.java | 71 ++++++++++++++++++ 7 files changed, 244 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 7aee070..a0cd2ed 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -23,6 +23,7 @@ public enum ErrorCode { USER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 이메일의 회원을 찾을 수 없습니다."), ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "ACCOUNT-003", "이미 다른 사용자와 연동된 소셜 계정입니다."), SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-004", "연동된 소셜 계정 정보를 찾을 수 없습니다."), + USER_SETTING_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-005", "해당 아이디의 설정을 찾을 수 없습니다."), // 소셜 로그인 관련 OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java new file mode 100644 index 0000000..390268f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java @@ -0,0 +1,49 @@ +package com.teamEWSN.gitdeun.user.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import com.teamEWSN.gitdeun.user.service.UserSettingService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/settings") +@RequiredArgsConstructor +public class UserSettingController { + private final UserSettingService userSettingService; + + /** + * 현재 로그인된 사용자의 설정을 조회합니다. + * @param userDetails 인증된 사용자 정보 + * @return 현재 설정 정보를 담은 응답 + */ + @GetMapping + public ResponseEntity getUserSettings( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + // CustomUserDetails가 null인 경우는 Spring Security에서 처리하므로, 여기서는 null이 아님을 가정합니다. + UserSettingResponseDto responseDto = userSettingService.getSettings(userDetails.getId()); + return ResponseEntity.ok(responseDto); + } + + /** + * 현재 로그인된 사용자의 설정을 변경합니다. + * @param userDetails 인증된 사용자 정보 + * @param requestDto 변경할 설정 정보를 담은 요청 DTO + * @return 변경된 설정 정보를 담은 응답 + */ + @PatchMapping // 리소스의 일부만 변경하므로 PATCH가 더 적합합니다. + public ResponseEntity updateUserSettings( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody UserSettingUpdateRequestDto requestDto + ) { + UserSettingResponseDto responseDto = userSettingService.updateSettings(userDetails.getId(), requestDto); + return ResponseEntity.ok(responseDto); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java new file mode 100644 index 0000000..4f54f79 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSettingResponseDto { + private UserSetting.DisplayTheme theme; + private UserSetting.UserMode mode; + private boolean emailNotification; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java new file mode 100644 index 0000000..d7683d3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSettingUpdateRequestDto { + @NotNull(message = "테마를 선택해주세요.") + private UserSetting.DisplayTheme theme; + + @NotNull(message = "모드를 선택해주세요.") + private UserSetting.UserMode mode; + + @NotNull(message = "이메일 수신 여부를 선택해주세요.") + private Boolean emailNotification; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java new file mode 100644 index 0000000..773133e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java @@ -0,0 +1,72 @@ +package com.teamEWSN.gitdeun.user.entity; + +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import jakarta.persistence.Entity; +import lombok.*; +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_settings") +public class UserSetting { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + // 화면 테마 (LIGHT, DARK) + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'LIGHT'") + private DisplayTheme theme = DisplayTheme.LIGHT; + + // 사용자 모드 (GENERAL, DEVELOPER) + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'GENERAL'") + private UserMode mode = UserMode.GENERAL; + + @Builder.Default + @Column(name = "email_notification", nullable = false) + @ColumnDefault("true") + private boolean emailNotification = true; + + + public enum DisplayTheme { + LIGHT, DARK + } + + public enum UserMode { + GENERAL, DEVELOPER + } + + @Builder + private UserSetting(User user, DisplayTheme theme, UserMode mode, boolean emailNotification) { + this.user = user; + this.theme = theme; + this.mode = mode; + this.emailNotification = emailNotification; + } + + public static UserSetting createDefault(User user) { + return UserSetting.builder() + .user(user) + .build(); + } + + public void update(UserSettingUpdateRequestDto dto) { + this.theme = dto.getTheme(); + this.mode = dto.getMode(); + this.emailNotification = dto.getEmailNotification(); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java new file mode 100644 index 0000000..fb0228a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.user.repository; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserSettingRepository extends JpaRepository { + + // 사용자 ID로 설정 조회 + Optional findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java new file mode 100644 index 0000000..cfbb38c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java @@ -0,0 +1,71 @@ +package com.teamEWSN.gitdeun.user.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.user.repository.UserSettingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // 기본적으로 읽기 전용 트랜잭션 사용 +public class UserSettingService { + + private final UserSettingRepository userSettingRepository; + private final UserRepository userRepository; + + /** + * 사용자 ID로 설정 정보를 조회합니다. + * 설정이 없는 경우 기본값을 생성하고 저장한 뒤 반환합니다. + * @param userId 조회할 사용자의 ID + * @return 사용자의 설정 정보 DTO + */ + @Transactional // 이 메서드는 쓰기 작업이 발생할 수 있으므로 @Transactional을 명시 + public UserSettingResponseDto getSettings(Long userId) { + UserSetting userSetting = userSettingRepository.findByUserId(userId) + .orElseGet(() -> { + // 설정이 없는 경우, 기본 설정을 생성 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + UserSetting defaultSetting = UserSetting.createDefault(user); + return userSettingRepository.save(defaultSetting); + }); + + return new UserSettingResponseDto( + userSetting.getTheme(), + userSetting.getMode(), + userSetting.isEmailNotification() + ); + } + + /** + * 사용자 설정을 업데이트합니다. + * @param userId 업데이트할 사용자의 ID + * @param requestDto 업데이트할 설정 내용 + * @return 업데이트된 사용자의 설정 정보 DTO + */ + @Transactional // 쓰기 작업을 위한 @Transactional + public UserSettingResponseDto updateSettings(Long userId, UserSettingUpdateRequestDto requestDto) { + // findByUserId를 사용하여 UserSetting을 직접 찾습니다. + UserSetting userSetting = userSettingRepository.findByUserId(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_SETTING_NOT_FOUND_BY_ID)); + + // 엔티티의 update 메서드를 사용하여 상태 변경 + userSetting.update(requestDto); + + // 변경된 내용을 DTO로 변환하여 반환 (userSettingRepository.save()는 필요 없음) + // 트랜잭션이 커밋될 때 변경 감지(Dirty Checking)에 의해 자동으로 DB에 반영됩니다. + return new UserSettingResponseDto( + userSetting.getTheme(), + userSetting.getMode(), + userSetting.isEmailNotification() + ); + } +} + From 83a6b20a9b097fe5158bf0feba2fa0605c041977 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 26 Jul 2025 10:54:16 +0900 Subject: [PATCH 29/60] =?UTF-8?q?style:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=EB=AA=85=20repo=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/oauth/service/GitHubApiHelper.java | 3 --- .../gitdeun/common/oauth/service/GoogleApiHelper.java | 2 -- .../com/teamEWSN/gitdeun/repo/controller/RepoController.java | 5 +++++ src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java | 5 +++++ src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java | 5 +++++ .../com/teamEWSN/gitdeun/repo/repository/RepoRepository.java | 5 +++++ .../java/com/teamEWSN/gitdeun/repo/service/RepoService.java | 5 +++++ .../gitdeun/repository/controller/RepositoryController.java | 5 ----- .../com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java | 5 ----- .../com/teamEWSN/gitdeun/repository/entity/Repository.java | 5 ----- .../gitdeun/repository/repository/RepositoryRepository.java | 5 ----- .../gitdeun/repository/service/RepositoryService.java | 5 ----- .../java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java | 1 - .../java/com/teamEWSN/gitdeun/user/service/AuthService.java | 2 -- 14 files changed, 25 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java index fffd828..c87661b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -3,7 +3,6 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; -import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -11,8 +10,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java index e9514d3..7a0d3c8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -2,7 +2,6 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; -import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,7 +15,6 @@ import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; -import java.util.Optional; @Slf4j @Component diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java new file mode 100644 index 0000000..b903ef3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.controller; + +public class RepoController { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java new file mode 100644 index 0000000..9c8714f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.dto; + +public class RepoDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java new file mode 100644 index 0000000..c527d3f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.entity; + +public class Repo { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java new file mode 100644 index 0000000..7d3b49b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.repository; + +public class RepoRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java new file mode 100644 index 0000000..9eaf262 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.service; + +public class RepoService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java b/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java deleted file mode 100644 index ced6177..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.controller; - -public class RepositoryController { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java b/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java deleted file mode 100644 index 603d314..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.dto; - -public class RepositoryDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java b/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java deleted file mode 100644 index 2d51bbe..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.entity; - -public class Repository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java deleted file mode 100644 index 6abc5b4..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.repository; - -public class RepositoryRepository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java b/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java deleted file mode 100644 index 28097f8..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.service; - -public class RepositoryService { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java index c05807e..cdf4d13 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java @@ -4,7 +4,6 @@ import lombok.Builder; import lombok.Getter; -import java.time.LocalDate; @Getter @Builder diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index d9a4009..4543ca7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -9,8 +9,6 @@ import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; -import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; -import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; From b20745424b0f11b24b5434f5a032ce2dcc320095 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 26 Jul 2025 10:54:33 +0900 Subject: [PATCH 30/60] =?UTF-8?q?feat:=20UserSettingMapper=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/mapper/UserSettingMapper.java | 13 ++++++++++ .../user/service/UserSettingService.java | 24 ++++++------------- 2 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java diff --git a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java new file mode 100644 index 0000000..916e774 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.user.mapper; + +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface UserSettingMapper { + + UserSettingResponseDto toResponseDto(UserSetting userSetting); + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java index cfbb38c..a3a00ad 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java @@ -6,6 +6,7 @@ import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.entity.UserSetting; +import com.teamEWSN.gitdeun.user.mapper.UserSettingMapper; import com.teamEWSN.gitdeun.user.repository.UserRepository; import com.teamEWSN.gitdeun.user.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; @@ -14,19 +15,19 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) // 기본적으로 읽기 전용 트랜잭션 사용 public class UserSettingService { private final UserSettingRepository userSettingRepository; private final UserRepository userRepository; + private final UserSettingMapper userSettingMapper; /** - * 사용자 ID로 설정 정보를 조회합니다. + * 사용자 ID로 설정 정보 조회 * 설정이 없는 경우 기본값을 생성하고 저장한 뒤 반환합니다. * @param userId 조회할 사용자의 ID * @return 사용자의 설정 정보 DTO */ - @Transactional // 이 메서드는 쓰기 작업이 발생할 수 있으므로 @Transactional을 명시 + @Transactional public UserSettingResponseDto getSettings(Long userId) { UserSetting userSetting = userSettingRepository.findByUserId(userId) .orElseGet(() -> { @@ -37,35 +38,24 @@ public UserSettingResponseDto getSettings(Long userId) { return userSettingRepository.save(defaultSetting); }); - return new UserSettingResponseDto( - userSetting.getTheme(), - userSetting.getMode(), - userSetting.isEmailNotification() - ); + return userSettingMapper.toResponseDto(userSetting); } /** - * 사용자 설정을 업데이트합니다. + * 사용자 설정 업데이트 * @param userId 업데이트할 사용자의 ID * @param requestDto 업데이트할 설정 내용 * @return 업데이트된 사용자의 설정 정보 DTO */ @Transactional // 쓰기 작업을 위한 @Transactional public UserSettingResponseDto updateSettings(Long userId, UserSettingUpdateRequestDto requestDto) { - // findByUserId를 사용하여 UserSetting을 직접 찾습니다. UserSetting userSetting = userSettingRepository.findByUserId(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_SETTING_NOT_FOUND_BY_ID)); // 엔티티의 update 메서드를 사용하여 상태 변경 userSetting.update(requestDto); - // 변경된 내용을 DTO로 변환하여 반환 (userSettingRepository.save()는 필요 없음) - // 트랜잭션이 커밋될 때 변경 감지(Dirty Checking)에 의해 자동으로 DB에 반영됩니다. - return new UserSettingResponseDto( - userSetting.getTheme(), - userSetting.getMode(), - userSetting.isEmailNotification() - ); + return userSettingMapper.toResponseDto(userSetting); } } From 195649e28d0eb03c1619a6cb442e8c062e63d519 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 26 Jul 2025 11:13:23 +0900 Subject: [PATCH 31/60] =?UTF-8?q?feat:=20UserSettingMapper=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../teamEWSN/gitdeun/user/controller/UserSettingController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java index 390268f..8c26416 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java @@ -27,7 +27,6 @@ public class UserSettingController { public ResponseEntity getUserSettings( @AuthenticationPrincipal CustomUserDetails userDetails ) { - // CustomUserDetails가 null인 경우는 Spring Security에서 처리하므로, 여기서는 null이 아님을 가정합니다. UserSettingResponseDto responseDto = userSettingService.getSettings(userDetails.getId()); return ResponseEntity.ok(responseDto); } From 8f8260aee67a356c8ae3a6ae802932bdd1575558 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 26 Jul 2025 13:07:07 +0900 Subject: [PATCH 32/60] =?UTF-8?q?feat:=20=ED=95=84=EC=88=98=20Entity=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CodeReferenceController.java | 3 +- .../codereference/entity/CodeReference.java | 28 ++++++++++- .../gitdeun/codereview/entity/CodeReview.java | 45 +++++++++++++++++- .../codereview/entity/CodeReviewStatus.java | 6 +++ .../comment/controller/CommentController.java | 4 ++ .../comment/dto/CommentResponseDto.java | 5 ++ .../comment/entity/AttachmentType.java | 6 +++ .../gitdeun/comment/entity/Comment.java | 44 ++++++++++++++++++ .../comment/entity/CommentAttachment.java | 37 +++++++++++++++ .../gitdeun/comment/entity/EmojiType.java | 9 ++++ .../comment/repository/CommentRepository.java | 5 ++ .../comment/service/CommentService.java | 5 ++ .../common/oauth/entity/OauthProvider.java | 2 +- .../gitdeun/invitation/entity/Invitation.java | 44 +++++++++++++++++- .../invitation/entity/InvitationStatus.java | 6 +++ .../meeting/controller/MeetingController.java | 4 ++ .../meeting/dto/MeetingResponseDto.java | 5 ++ .../gitdeun/meeting/entity/Meeting.java | 46 +++++++++++++++++++ .../gitdeun/meeting/entity/Participant.java | 36 +++++++++++++++ .../gitdeun/meeting/entity/ParticipantId.java | 13 ++++++ .../meeting/repository/MeetingRepository.java | 5 ++ .../meeting/service/MeetingService.java | 5 ++ .../gitdeun/mindmap/entity/Mindmap.java | 44 +++++++++++++++++- .../gitdeun/mindmap/entity/MindmapType.java | 6 +++ .../controller/MindmapEdgeController.java | 5 ++ .../mindmapedge/dto/MindmapEdgeDto.java | 5 ++ .../gitdeun/mindmapedge/entity/EdgeType.java | 7 +++ .../mindmapedge/entity/MindmapEdge.java | 42 +++++++++++++++++ .../repository/MindmapEdgeRepository.java | 5 ++ .../service/MindmapEdgeService.java | 5 ++ .../mindmapnode/entity/MindmapNode.java | 40 +++++++++++++++- .../teamEWSN/gitdeun/repo/entity/Repo.java | 29 +++++++++++- .../repo/repository/RepoRepository.java | 3 ++ .../visithistory/entity/VisitHistory.java | 33 ++++++++++++- .../visithistory/entity/VisitHistoryId.java | 13 ++++++ 35 files changed, 587 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReviewStatus.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/entity/AttachmentType.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/invitation/entity/InvitationStatus.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/meeting/controller/MeetingController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/meeting/dto/MeetingResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/meeting/entity/ParticipantId.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/meeting/repository/MeetingRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/meeting/service/MeetingService.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java index 904cce0..35d093f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java @@ -1,5 +1,4 @@ package com.teamEWSN.gitdeun.codereference.controller; public class CodeReferenceController { - -} \ No newline at end of file +} diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java index e71e410..7eae685 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java @@ -1,5 +1,31 @@ package com.teamEWSN.gitdeun.codereference.entity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "code_reference") public class CodeReference { - + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "node_id", nullable = false) + private MindmapNode node; + + @Column(name = "file_path", columnDefinition = "TEXT", nullable = false) + private String filePath; + + @Column(name = "start_line", length = 255) + private String startLine; + + @Column(name = "end_line", length = 255) + private String endLine; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java index f36f8e8..84b1779 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java @@ -1,5 +1,46 @@ package com.teamEWSN.gitdeun.codereview.entity; -public class CodeReview { - +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Code_Review") +public class CodeReview extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private User author; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "node_id", nullable = false) + private MindmapNode node; + + @Column(name = "ref_id") + private Long refId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @ColumnDefault("'PENDING'") + private CodeReviewStatus status; + + @Column(name = "comment_cnt") + @ColumnDefault("0") + private Integer commentCount; + + @Column(name = "unresolved_thread_cnt") + @ColumnDefault("0") + private Integer unresolvedThreadCount; + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReviewStatus.java b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReviewStatus.java new file mode 100644 index 0000000..99a0912 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReviewStatus.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.codereview.entity; + +public enum CodeReviewStatus { + PENDING, + RESOLVED +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java new file mode 100644 index 0000000..154605b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java @@ -0,0 +1,4 @@ +package com.teamEWSN.gitdeun.comment.controller; + +public class CommentController { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java new file mode 100644 index 0000000..e306b6e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.comment.dto; + +public class CommentResponseDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/AttachmentType.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/AttachmentType.java new file mode 100644 index 0000000..7433cc5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/AttachmentType.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.comment.entity; + +public enum AttachmentType { + IMAGE, + FILE +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java new file mode 100644 index 0000000..72e028c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java @@ -0,0 +1,44 @@ +package com.teamEWSN.gitdeun.comment.entity; + +import com.teamEWSN.gitdeun.codereview.entity.CodeReview; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Comment") +public class Comment extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "code_review_id", nullable = false) + private CodeReview codeReview; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private Comment parentComment; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "emoji_type") + private EmojiType emojiType; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java new file mode 100644 index 0000000..42c380a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.comment.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Comment_Attachment") +public class CommentAttachment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + private Comment comment; + + @Column(length = 255, nullable = false) + private String url; + + @Column(name = "file_name", length = 200, nullable = false) + private String fileName; + + @Column(name = "mime_type", length = 100, nullable = false) + private String mimeType; + + @Column(nullable = false) + private Long size; + + @Enumerated(EnumType.STRING) + @Column(name = "attachment_type", nullable = false) + private AttachmentType attachmentType; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java new file mode 100644 index 0000000..96c76fc --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.comment.entity; + +public enum EmojiType { + QUESTION, + IDEA, + BUG, + IMPORTANT, + LOVE +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java new file mode 100644 index 0000000..e9ce416 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.comment.repository; + +public class CommentRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java new file mode 100644 index 0000000..1bbffe9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.comment.service; + +public class CommentService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java index f573228..52054d9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java @@ -2,5 +2,5 @@ public enum OauthProvider { GOOGLE, - GITHUB, + GITHUB } diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java index 720d849..9879940 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java @@ -1,5 +1,47 @@ package com.teamEWSN.gitdeun.invitation.entity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Invitation") public class Invitation { - + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + @Column(length = 36, nullable = false, unique = true) + private String token; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @ColumnDefault("'READ_ONLY'") + private InvitationStatus status; + + @CreationTimestamp + @Column(name = "invited_at", updatable = false) + private LocalDateTime invitedAt; + + @Column(name = "is_accept", nullable = false) + @ColumnDefault("false") + private boolean isAccept; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/InvitationStatus.java b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/InvitationStatus.java new file mode 100644 index 0000000..09101f3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/InvitationStatus.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.invitation.entity; + +public enum InvitationStatus { + READ_ONLY, + EDIT_ALLOWED +} diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/controller/MeetingController.java b/src/main/java/com/teamEWSN/gitdeun/meeting/controller/MeetingController.java new file mode 100644 index 0000000..9241da3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/controller/MeetingController.java @@ -0,0 +1,4 @@ +package com.teamEWSN.gitdeun.meeting.controller; + +public class MeetingController { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/dto/MeetingResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/meeting/dto/MeetingResponseDto.java new file mode 100644 index 0000000..0e4edf7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/dto/MeetingResponseDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.meeting.dto; + +public class MeetingResponseDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java new file mode 100644 index 0000000..1f1676c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java @@ -0,0 +1,46 @@ +package com.teamEWSN.gitdeun.meeting.entity; + +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Meeting") +public class Meeting extends CreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "node_id", nullable = false) + private MindmapNode node; + + @Column(name = "room_name", length = 255, nullable = false) + private String roomName; + + @Column(length = 255) + private String title; + + @Column(columnDefinition = "TEXT") + private String summary; + + @Column(name = "shared_to_ai", nullable = false) + @ColumnDefault("false") + private boolean sharedToAI; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "ended_at") + private LocalDateTime endedAt; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java new file mode 100644 index 0000000..65ccea3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java @@ -0,0 +1,36 @@ +package com.teamEWSN.gitdeun.meeting.entity; + +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Participant") +@IdClass(ParticipantId.class) +public class Participant { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_id", nullable = false) + private Meeting meeting; + + @CreationTimestamp + @Column(name = "joined_at", nullable = false, updatable = false) + private LocalDateTime joinedAt; + + @Column(name = "left_at") + private LocalDateTime leftAt; + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/ParticipantId.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/ParticipantId.java new file mode 100644 index 0000000..673a00f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/ParticipantId.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.meeting.entity; + +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@NoArgsConstructor +@EqualsAndHashCode +public class ParticipantId implements Serializable { + private Long user; + private Long meeting; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/repository/MeetingRepository.java b/src/main/java/com/teamEWSN/gitdeun/meeting/repository/MeetingRepository.java new file mode 100644 index 0000000..c38249a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/repository/MeetingRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.meeting.repository; + +public class MeetingRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/service/MeetingService.java b/src/main/java/com/teamEWSN/gitdeun/meeting/service/MeetingService.java new file mode 100644 index 0000000..4f11b95 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/service/MeetingService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.meeting.service; + +public class MeetingService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index fa5027d..ff1eb22 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -1,5 +1,45 @@ package com.teamEWSN.gitdeun.mindmap.entity; -public class Mindmap { - +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Mindmap") +public class Mindmap extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "repo_id", nullable = false) + private Repo repo; + + @Column(columnDefinition = "TEXT") + private String prompt; + + @Column(length = 100, nullable = false) + private String branch; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private MindmapType type; + + @Column(name = "Field", length = 255, nullable = false) + @ColumnDefault("'확인용 (n)'") + private String field; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "map_data", columnDefinition = "json", nullable = false) + private String mapData; + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java new file mode 100644 index 0000000..dfeefec --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.mindmap.entity; + +public enum MindmapType { + DEV, + CHECK +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java new file mode 100644 index 0000000..f9cc934 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.controller; + +public class MindmapEdgeController { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java new file mode 100644 index 0000000..f7b9ab7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.dto; + +public class MindmapEdgeDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java new file mode 100644 index 0000000..0807276 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java @@ -0,0 +1,7 @@ +package com.teamEWSN.gitdeun.mindmapedge.entity; + +public enum EdgeType { + CROSS, + PARENT, + CHILD +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java new file mode 100644 index 0000000..17a8409 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.mindmapedge.entity; + +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Mindmap_Edge") +public class MindmapEdge extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "from_node_id", nullable = false) + private MindmapNode fromNode; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "to_node_id", nullable = false) + private MindmapNode toNode; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private EdgeType type; + + @Column(nullable = false) + @ColumnDefault("0") + private BigDecimal strength; + + @Column(name = "arango_key") + private Long arangoKey; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java new file mode 100644 index 0000000..73abaf4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.repository; + +public class MindmapEdgeRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java new file mode 100644 index 0000000..ab345d5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.service; + +public class MindmapEdgeService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java index 40ae180..59b15c3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java @@ -1,5 +1,41 @@ package com.teamEWSN.gitdeun.mindmapnode.entity; -public class MindmapNode { - +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Mindmap_Node") +public class MindmapNode extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + @Column(length = 100, nullable = false) + private String label; + + @Column(columnDefinition = "TEXT", nullable = false) + private String path; + + @Column(nullable = false) + @ColumnDefault("1") + private Integer depth; + + @Column(name = "arango_key", length = 64) + private String arangoKey; + + @Column(name = "Importance", nullable = false) + @ColumnDefault("0") + private Double importance; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index c527d3f..ed6ad5c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -1,5 +1,32 @@ package com.teamEWSN.gitdeun.repo.entity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Repo") public class Repo { - + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "github_repo_url", length = 512, nullable = false) + private String githubRepoUrl; + + @Column(name = "default_branch", length = 100) + private String defaultBranch; + + @Column(name = "last_synced_at") + private LocalDateTime lastSyncedAt; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java index 7d3b49b..dbe2f7e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java @@ -1,5 +1,8 @@ package com.teamEWSN.gitdeun.repo.repository; +import org.springframework.stereotype.Repository; + +@Repository public class RepoRepository { } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java index 68b7c40..3292b63 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java @@ -1,5 +1,36 @@ package com.teamEWSN.gitdeun.visithistory.entity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Visit_History") +@IdClass(VisitHistoryId.class) public class VisitHistory { - + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + @Column(name = "last_visited_at", nullable = false) + private LocalDateTime lastVisitedAt; + + @Column(nullable = false) + @ColumnDefault("false") + private boolean pinned; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java new file mode 100644 index 0000000..40b0755 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.visithistory.entity; + +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@NoArgsConstructor +@EqualsAndHashCode +public class VisitHistoryId implements Serializable { + private Long user; + private Long mindmap; +} From bb03d82dcefe4f211ce50269dda9b28cfce7e89b Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sun, 27 Jul 2025 23:10:48 +0900 Subject: [PATCH 33/60] =?UTF-8?q?feat:=20FastApiClient,=20FastApiConfig=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95,=20=EB=B0=9B=EC=95=84=EC=98=AC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=9E=84=EC=8B=9C=20Dto=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/WebClientConfig.java | 3 +- .../config/fastapi/AnalysisResultDto.java | 21 +++++++++ .../common/config/fastapi/FastApiClient.java | 43 +++++++++++++++++++ .../fastapi/FastApiCommitTimeResponse.java | 12 ++++++ .../common/config/fastapi/FastApiConfig.java | 21 +++++++++ .../common/oauth/service/GitHubApiHelper.java | 7 ++- .../common/oauth/service/GoogleApiHelper.java | 7 ++- src/main/resources/application-dev.yml | 5 ++- src/main/resources/application-prod.yml | 5 ++- 9 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/AnalysisResultDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiClient.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiCommitTimeResponse.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiConfig.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java index f9e78f8..6022d60 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java @@ -7,7 +7,8 @@ @Configuration public class WebClientConfig { - @Bean + // OAuth 및 기타 외부 API 통신을 위한 범용 WebClient Bean + @Bean("oauthWebClient") public WebClient webClient() { return WebClient.builder().build(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/AnalysisResultDto.java new file mode 100644 index 0000000..596a987 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/AnalysisResultDto.java @@ -0,0 +1,21 @@ +package com.teamEWSN.gitdeun.common.config.fastapi; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class AnalysisResultDto { + // FastAPI가 반환하는 Repo 관련 정보 + private String defaultBranch; + private String language; + private String description; + private LocalDateTime githubLastUpdatedAt; + + // FastAPI가 반환하는 Mindmap 관련 정보 + private String mapData; + private MindmapType type; + private String prompt; + // TODO: FastAPI 응답에 맞춰 필드 정의 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiClient.java new file mode 100644 index 0000000..2ce96cd --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiClient.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.common.config.fastapi; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDateTime; + +@Component +public class FastApiClient { + + private final WebClient webClient; // FastApiConfig에서 생성한 Bean을 주입받음 + + public FastApiClient(@Qualifier("fastApiWebClient") WebClient webClient) { + this.webClient = webClient; + } + + /** + * FastAPI 서버에 특정 GitHub 리포지토리의 최신 커밋 시간을 요청합니다. + * @param githubRepoUrl 조회할 리포지토리의 URL + * @return 최신 커밋 시간 + */ + public LocalDateTime fetchLatestCommitTime(String githubRepoUrl) { + // FastAPI의 가벼운 엔드포인트(예: /check-commit-time)를 호출합니다. + FastApiCommitTimeResponse response = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/check-commit-time") // FastAPI에 정의된 엔드포인트 경로 + .queryParam("url", githubRepoUrl) // 쿼리 파라미터로 URL 전달 + .build()) + .retrieve() // 응답을 받아옴 + .bodyToMono(FastApiCommitTimeResponse.class) // 응답 본문을 DTO로 변환 + .block(); // 비동기 응답을 동기적으로 기다림 + + // null 체크 후 날짜 반환 + if (response == null) { + throw new RuntimeException("FastAPI 서버로부터 최신 커밋 시간 정보를 받아오지 못했습니다."); + } + return response.getLatestCommitAt(); + } + + // TODO: requestAnalysis 등 다른 FastAPI 호출 메서드들도 여기에 구현 + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiCommitTimeResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiCommitTimeResponse.java new file mode 100644 index 0000000..2371553 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiCommitTimeResponse.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.common.config.fastapi; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter // JSON 역직렬화를 위해 필요 +class FastApiCommitTimeResponse { + private LocalDateTime latestCommitAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiConfig.java new file mode 100644 index 0000000..2caf502 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiConfig.java @@ -0,0 +1,21 @@ +package com.teamEWSN.gitdeun.common.config.fastapi; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class FastApiConfig { + + @Value("${fastapi.base-url}") + private String baseUrl; + + // FastAPI 서버와 통신하기 위한 전용 WebClient Bean + @Bean("fastApiWebClient") + public WebClient webClient() { + return WebClient.builder() + .baseUrl(baseUrl) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java index c87661b..12a779d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -3,8 +3,8 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; @@ -20,11 +20,14 @@ @Slf4j @Component -@RequiredArgsConstructor public class GitHubApiHelper { private final WebClient webClient; + public GitHubApiHelper(@Qualifier("oauthWebClient") WebClient webClient) { + this.webClient = webClient; + } + @Value("${spring.security.oauth2.client.registration.github.client-id}") private String clientId; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java index 7a0d3c8..dadc42b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -3,8 +3,8 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; @@ -18,11 +18,14 @@ @Slf4j @Component -@RequiredArgsConstructor public class GoogleApiHelper { private final WebClient webClient; + public GoogleApiHelper(@Qualifier("oauthWebClient") WebClient webClient) { + this.webClient = webClient; + } + @Value("${spring.security.oauth2.client.registration.google.client-id}") private String googleClientId; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 5f65fb5..8fa6f61 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -30,4 +30,7 @@ app: front-url: http://localhost:3000 cookie: secure: false - same-site: Lax \ No newline at end of file + same-site: Lax + +fastapi: + base-url: http://localhost:8000 # 내 PC에서 개발할 때의 주소 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index fbd1c5f..07c7b4e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -28,4 +28,7 @@ app: front-url: https://gitdeun.site cookie: secure: true - same-site: None \ No newline at end of file + same-site: None + +fastapi: + base-url: http://fastapi-server:8000 # Docker 네트워크 내부에서 사용할 주소 \ No newline at end of file From e06b29fc76ba35b9dbf154748822a32a8778ee86 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sun, 27 Jul 2025 23:11:16 +0900 Subject: [PATCH 34/60] =?UTF-8?q?feat:=20Repo=20=EB=8F=84=EB=A9=94?= =?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 --- .../gitdeun/common/exception/ErrorCode.java | 4 ++ .../mindmap/controller/MindmapController.java | 5 +- .../gitdeun/mindmap/entity/Mindmap.java | 6 ++ .../gitdeun/mindmap/mapper/MindmapMapper.java | 8 +++ .../mindmap/repository/MindmapRepository.java | 7 ++- .../repo/controller/RepoController.java | 39 +++++++++++- .../teamEWSN/gitdeun/repo/dto/RepoDto.java | 5 -- .../gitdeun/repo/dto/RepoResponseDto.java | 23 +++++++ .../repo/dto/RepoUpdateCheckResponseDto.java | 11 ++++ .../teamEWSN/gitdeun/repo/entity/Repo.java | 37 ++++++++--- .../gitdeun/repo/mapper/RepoMapper.java | 13 ++++ .../repo/repository/RepoRepository.java | 9 ++- .../gitdeun/repo/service/RepoService.java | 61 ++++++++++++++++++- 13 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index a0cd2ed..05d382d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -34,6 +34,10 @@ public enum ErrorCode { SOCIAL_ACCOUNT_CONNECT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-006", "소셜 계정 연동에 실패했습니다."), SOCIAL_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "리프레시 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), + // 리포지토리 관련 + REPO_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "REPO-001", "해당 ID로 요청한 리포지토리를 찾을 수 없습니다."), + REPO_NOT_FOUND_BY_URL(HttpStatus.NOT_FOUND, "REPO-002", "해당 URL로 요청한 리포지토리를 찾을 수 없습니다."), + // S3 파일 관련 // Client Errors (4xx) FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index 10fedef..dc7cfd5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,5 +1,8 @@ package com.teamEWSN.gitdeun.mindmap.controller; public class MindmapController { - + + + // TODO: 마인드맵 로딩 시 자동 확인 + 새로고침 시 재동기화 + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index ff1eb22..d26a8a3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -2,6 +2,7 @@ import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -24,6 +25,11 @@ public class Mindmap extends AuditedEntity { @JoinColumn(name = "repo_id", nullable = false) private Repo repo; + // owner + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + @Column(columnDefinition = "TEXT") private String prompt; diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java new file mode 100644 index 0000000..3b027ba --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java @@ -0,0 +1,8 @@ +package com.teamEWSN.gitdeun.mindmap.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface MindmapMapper { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java index d866bea..4e26d5f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java @@ -1,5 +1,10 @@ package com.teamEWSN.gitdeun.mindmap.repository; -public class MindmapRepository { +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MindmapRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java index b903ef3..6eb6968 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java @@ -1,5 +1,42 @@ package com.teamEWSN.gitdeun.repo.controller; +import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; +import com.teamEWSN.gitdeun.repo.dto.RepoUpdateCheckResponseDto; +import com.teamEWSN.gitdeun.repo.service.RepoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/repos") public class RepoController { - + + private final RepoService repoService; + + // 리포지토리 URL을 통한 등록 확인 + @GetMapping("/check") + public ResponseEntity checkRepoExists(@RequestParam String url) { + RepoResponseDto response = repoService.findRepoByUrl(url); + return ResponseEntity.ok(response); + } + + // 리포지토리 정보 조회 + @GetMapping("/{repoId}") + public ResponseEntity getRepoInfo(@PathVariable Long repoId) { + RepoResponseDto response = repoService.findRepoById(repoId); + return ResponseEntity.ok(response); + } + + /** + * 특정 리포지토리에 대한 업데이트 필요 여부 확인 + */ + @GetMapping("/{repoId}/status") + public ResponseEntity getRepoUpdateStatus(@PathVariable Long repoId) { + RepoUpdateCheckResponseDto response = repoService.checkUpdateNeeded(repoId); + return ResponseEntity.ok(response); + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java deleted file mode 100644 index 9c8714f..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repo.dto; - -public class RepoDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java new file mode 100644 index 0000000..218143c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java @@ -0,0 +1,23 @@ +package com.teamEWSN.gitdeun.repo.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class RepoResponseDto { + + private final Long repoId; + private final String githubRepoUrl; + private final String defaultBranch; + private final String language; + private final String description; + private final LocalDateTime githubLastUpdatedAt; + private final LocalDateTime lastSyncedAt; + + @Setter // Mapper의 @AfterMapping에서 이 값을 설정 + private boolean updateNeeded; // 재동기화 필요 여부 플래그 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java new file mode 100644 index 0000000..199cdd7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java @@ -0,0 +1,11 @@ +package com.teamEWSN.gitdeun.repo.dto; + + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class RepoUpdateCheckResponseDto { + private final boolean updateNeeded; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index ed6ad5c..d035703 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -1,8 +1,9 @@ package com.teamEWSN.gitdeun.repo.entity; -import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.common.config.fastapi.AnalysisResultDto; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -17,16 +18,34 @@ public class Repo { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Column(name = "github_repo_url", length = 512, nullable = false) + @Column(name = "github_repo_url", length = 512, nullable = false, unique = true) private String githubRepoUrl; @Column(name = "default_branch", length = 100) - private String defaultBranch; + private String defaultBranch; // 기본 브랜치 + + @Column(length = 50) + private String language; // 주요 언어 + + @Column(columnDefinition = "TEXT") + private String description; // 설명 + + @Column(name = "github_last_updated_at") + private LocalDateTime githubLastUpdatedAt; // GitHub 브랜치 최신 커밋 시간 (commit.committer.date) + + + @Builder + public Repo(String githubRepoUrl, String defaultBranch, String language, String description, LocalDateTime githubLastUpdatedAt) { + this.githubRepoUrl = githubRepoUrl; + this.defaultBranch = defaultBranch; + this.language = language; + this.description = description; + this.githubLastUpdatedAt = githubLastUpdatedAt; + } - @Column(name = "last_synced_at") - private LocalDateTime lastSyncedAt; + public void updateWithAnalysis(AnalysisResultDto result) { + this.language = result.getLanguage(); + this.description = result.getDescription(); + this.githubLastUpdatedAt = result.getGithubLastUpdatedAt(); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java b/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java new file mode 100644 index 0000000..fe6db82 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.repo.mapper; + +import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface RepoMapper { + + RepoResponseDto toResponseDto(Repo repo); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java index dbe2f7e..2d7ec47 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java @@ -1,8 +1,13 @@ package com.teamEWSN.gitdeun.repo.repository; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository -public class RepoRepository { - +public interface RepoRepository extends JpaRepository { + + Optional findByGithubRepoUrl(String githubRepoUrl); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java index 9eaf262..e85a065 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -1,5 +1,64 @@ package com.teamEWSN.gitdeun.repo.service; +import com.teamEWSN.gitdeun.common.config.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; +import com.teamEWSN.gitdeun.repo.dto.RepoUpdateCheckResponseDto; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.repo.mapper.RepoMapper; +import com.teamEWSN.gitdeun.repo.repository.RepoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // 기본적으로는 읽기 전용, 데이터 변경 메서드에 @Transactional을 별도 추가 public class RepoService { - + + private final RepoRepository repoRepository; + private final RepoMapper repoMapper; + private final FastApiClient fastApiClient; + + /** + * Mapper를 통해 'updateNeeded' 플래그가 포함된 DTO를 반환합니다. + */ + public RepoResponseDto findRepoById(Long repoId) { + Repo repo = repoRepository.findById(repoId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_ID)); + return repoMapper.toResponseDto(repo); + } + + /** + * 리포지토리 URL로 정보를 조회 + */ + public RepoResponseDto findRepoByUrl(String url) { + Repo repo = repoRepository.findByGithubRepoUrl(url) + .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_URL)); + return repoMapper.toResponseDto(repo); + } + + /** + * 리포지토리의 최신 업데이트 상태를 실시간으로 확인 + * @param repoId 확인할 리포지토리의 ID + * @return 업데이트 필요 여부를 담은 DTO + */ + public RepoUpdateCheckResponseDto checkUpdateNeeded(Long repoId) { + // 시스템의 마지막 동기화 시간 조회 + Repo repo = repoRepository.findById(repoId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_ID)); + LocalDateTime lastSyncedAt = repo.getGithubLastUpdatedAt(); + + // FastAPI의 가벼운 API를 호출하여 GitHub의 최신 커밋 시간 조회 + LocalDateTime latestCommitAt = fastApiClient.fetchLatestCommitTime(repo.getGithubRepoUrl()); + + // 두 시간을 비교하여 업데이트 필요 여부 결정 + boolean isNeeded = latestCommitAt.isAfter(lastSyncedAt); + + return new RepoUpdateCheckResponseDto(isNeeded); + } + } \ No newline at end of file From cbefb241f987ff61963d8d7de4a055f719572b4a Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 28 Jul 2025 00:08:22 +0900 Subject: [PATCH 35/60] =?UTF-8?q?feat:=20oauth=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SocialTokenRefreshService.java | 19 +++------- .../user/controller/AuthController.java | 36 ++++++------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java index 2d2a0f5..41c22d0 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -1,6 +1,5 @@ package com.teamEWSN.gitdeun.common.oauth.service; -import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; @@ -10,11 +9,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import java.util.Optional; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.reactive.function.client.WebClient; + +import static com.teamEWSN.gitdeun.common.exception.ErrorCode.*; // 레포 및 마인드맵 호출 시 소셜로그인 토큰 갱신 호출 @@ -25,22 +24,14 @@ public class SocialTokenRefreshService { private final SocialConnectionRepository socialConnectionRepository; - private final WebClient webClient; - private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper' private final GitHubApiHelper gitHubApiHelper; private final GoogleApiHelper googleApiHelper; - @Value("${spring.security.oauth2.client.registration.google.client-id}") - private String googleClientId; - - @Value("${spring.security.oauth2.client.registration.google.client-secret}") - private String googleClientSecret; - // 기존 refreshToken 기반 갱신(주기적/자동 갱신) public void refreshSocialToken(Long userId, OauthProvider provider) { SocialConnection connection = socialConnectionRepository.findByUserIdAndProvider(userId, provider) - .orElseThrow(() -> new GlobalException(ErrorCode.SOCIAL_CONNECTION_NOT_FOUND)); + .orElseThrow(() -> new GlobalException(SOCIAL_CONNECTION_NOT_FOUND)); switch (provider) { case GOOGLE -> refreshGoogle(connection, Optional.empty(), Optional.empty()); @@ -81,7 +72,7 @@ private void refreshGoogle(SocialConnection conn, // 2. refreshToken 기반 재발급 (latestRefresh가 있으면 그것 사용, 없으면 기존) String refreshToUse = latestRefreshOpt.orElse(conn.getRefreshToken()); if (refreshToUse == null) { - throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + throw new GlobalException(INVALID_REFRESH_TOKEN); } GoogleTokenResponse res = googleApiHelper.refreshToken(refreshToUse); diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index acc86dd..2f8954a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -3,7 +3,9 @@ import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; +import com.teamEWSN.gitdeun.common.oauth.service.SocialTokenRefreshService; import com.teamEWSN.gitdeun.user.dto.UserTokenResponseDto; import com.teamEWSN.gitdeun.user.service.AuthService; import jakarta.servlet.http.HttpServletResponse; @@ -26,6 +28,7 @@ public class AuthController { private Long refreshTokenExpired; private final OAuthStateService oAuthStateService; + private final SocialTokenRefreshService socialTokenRefreshService; private final AuthService authService; private final CookieUtil cookieUtil; @@ -66,30 +69,13 @@ public ResponseEntity refreshAccessToken( return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); } -// /** -// * GitHub의 모든 OAuth 콜백을 처리하는 단일 엔드포인트 -// * @param code GitHub에서 제공하는 Authorization Code -// * @param userDetails 현재 로그인된 사용자 정보. 비로그인 상태면 null. -// * @return 로그인 또는 계정 연동 흐름에 따라 적절한 경로로 포워딩 또는 리디렉션 -// */ -// @GetMapping("/github/callback") -// public ResponseEntity githubCallback( -// @RequestParam("code") String code, -// @AuthenticationPrincipal CustomUserDetails userDetails, -// HttpServletResponse response // 쿠키 설정을 위해 필요 -// ) { -// -// if (userDetails != null) { -// // "계정 연동" 흐름 -// authService.connectGithubAccount(userDetails.getId(), code); -// // 성공했다는 응답 전달 -// return ResponseEntity.ok().body(Map.of("status", "success", "message", "계정 연동 성공!")); -// -// } else { -// // "최초 로그인" 흐름 -// GithubLoginResponseDto loginResponse = authService.loginWithGithub(code, response); -// return ResponseEntity.ok(loginResponse); -// } -// } + // 외부 OAuth 재발급 (Access 만료 → provider 선택) + @PostMapping("/oauth/refresh/{provider}") + public ResponseEntity refreshSocial( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable OauthProvider provider) { + socialTokenRefreshService.refreshSocialToken(user.getId(), provider); + return ResponseEntity.noContent().build(); + } } From 6b069de43a9e1031d8f54ded1dca7f9781475091 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 28 Jul 2025 00:14:47 +0900 Subject: [PATCH 36/60] =?UTF-8?q?refactor:=20fastapi=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/config/{fastapi => }/FastApiConfig.java | 2 +- .../gitdeun/common/{config => }/fastapi/FastApiClient.java | 3 ++- .../{config/fastapi => fastapi/dto}/AnalysisResultDto.java | 2 +- .../fastapi => fastapi/dto}/FastApiCommitTimeResponse.java | 4 ++-- src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java | 2 +- .../java/com/teamEWSN/gitdeun/repo/service/RepoService.java | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) rename src/main/java/com/teamEWSN/gitdeun/common/config/{fastapi => }/FastApiConfig.java (91%) rename src/main/java/com/teamEWSN/gitdeun/common/{config => }/fastapi/FastApiClient.java (93%) rename src/main/java/com/teamEWSN/gitdeun/common/{config/fastapi => fastapi/dto}/AnalysisResultDto.java (91%) rename src/main/java/com/teamEWSN/gitdeun/common/{config/fastapi => fastapi/dto}/FastApiCommitTimeResponse.java (66%) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java similarity index 91% rename from src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiConfig.java rename to src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java index 2caf502..430d07a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.common.config.fastapi; +package com.teamEWSN.gitdeun.common.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java similarity index 93% rename from src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiClient.java rename to src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index 2ce96cd..894e902 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -1,5 +1,6 @@ -package com.teamEWSN.gitdeun.common.config.fastapi; +package com.teamEWSN.gitdeun.common.fastapi; +import com.teamEWSN.gitdeun.common.fastapi.dto.FastApiCommitTimeResponse; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java similarity index 91% rename from src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/AnalysisResultDto.java rename to src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java index 596a987..ff5d85f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/AnalysisResultDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.common.config.fastapi; +package com.teamEWSN.gitdeun.common.fastapi.dto; import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; import lombok.Getter; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiCommitTimeResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java similarity index 66% rename from src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiCommitTimeResponse.java rename to src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java index 2371553..40f202e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/fastapi/FastApiCommitTimeResponse.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.common.config.fastapi; +package com.teamEWSN.gitdeun.common.fastapi.dto; import lombok.Getter; import lombok.Setter; @@ -7,6 +7,6 @@ @Getter @Setter // JSON 역직렬화를 위해 필요 -class FastApiCommitTimeResponse { +public class FastApiCommitTimeResponse { private LocalDateTime latestCommitAt; } diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index d035703..8f0be06 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -1,6 +1,6 @@ package com.teamEWSN.gitdeun.repo.entity; -import com.teamEWSN.gitdeun.common.config.fastapi.AnalysisResultDto; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java index e85a065..544add6 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -1,6 +1,6 @@ package com.teamEWSN.gitdeun.repo.service; -import com.teamEWSN.gitdeun.common.config.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; From 17460cc73c5cd5d1d3d68753e36872774710ae93 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Mon, 28 Jul 2025 04:14:54 +0900 Subject: [PATCH 37/60] =?UTF-8?q?refactor:=20repo=20url=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20=EB=93=B1=EB=A1=9D=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/repo/controller/RepoController.java | 5 +++-- .../gitdeun/repo/dto/RepoResponseDto.java | 4 ---- .../gitdeun/repo/service/RepoService.java | 16 ++++++---------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java index 6eb6968..eed00d3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java @@ -18,8 +18,9 @@ public class RepoController { // 리포지토리 URL을 통한 등록 확인 @GetMapping("/check") public ResponseEntity checkRepoExists(@RequestParam String url) { - RepoResponseDto response = repoService.findRepoByUrl(url); - return ResponseEntity.ok(response); + return repoService.findRepoByUrl(url) // Optional를 받음 + .map(ResponseEntity::ok) // 값이 있으면 200 OK와 함께 body에 담아 반환 + .orElseGet(() -> ResponseEntity.noContent().build()); // 값이 없으면 204 No Content 반환 } // 리포지토리 정보 조회 diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java index 218143c..870c276 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java @@ -2,7 +2,6 @@ import lombok.Builder; import lombok.Getter; -import lombok.Setter; import java.time.LocalDateTime; @@ -16,8 +15,5 @@ public class RepoResponseDto { private final String language; private final String description; private final LocalDateTime githubLastUpdatedAt; - private final LocalDateTime lastSyncedAt; - @Setter // Mapper의 @AfterMapping에서 이 값을 설정 - private boolean updateNeeded; // 재동기화 필요 여부 플래그 } diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java index 544add6..6994c92 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -23,22 +24,17 @@ public class RepoService { private final RepoMapper repoMapper; private final FastApiClient fastApiClient; - /** - * Mapper를 통해 'updateNeeded' 플래그가 포함된 DTO를 반환합니다. - */ + // 레포지토리 ID로 정보 조회 public RepoResponseDto findRepoById(Long repoId) { Repo repo = repoRepository.findById(repoId) .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_ID)); return repoMapper.toResponseDto(repo); } - /** - * 리포지토리 URL로 정보를 조회 - */ - public RepoResponseDto findRepoByUrl(String url) { - Repo repo = repoRepository.findByGithubRepoUrl(url) - .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_URL)); - return repoMapper.toResponseDto(repo); + // 리포지토리 URL을 통한 조회하여 등록 확인 + public Optional findRepoByUrl(String url) { + return repoRepository.findByGithubRepoUrl(url) + .map(repoMapper::toResponseDto); } /** From 5cdf23d6b39b9b33af9e0e1f7d2a6c7b2b66f8bb Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Tue, 29 Jul 2025 17:09:41 +0900 Subject: [PATCH 38/60] =?UTF-8?q?fix:=20JwtTokenProvider=20Principal?= =?UTF-8?q?=EC=9D=84=20=EA=B0=95=EC=A0=9C=20=EC=BA=90=EC=8A=A4=ED=8C=85=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/SchedulingConfig.java | 9 + .../gitdeun/common/config/SecurityPath.java | 11 +- .../common/fastapi/dto/AnalysisResultDto.java | 1 - .../teamEWSN/gitdeun/common/jwt/JwtToken.java | 9 + .../gitdeun/common/jwt/JwtTokenProvider.java | 182 ++++++++++++------ .../gitdeun/common/jwt/RefreshToken.java | 4 +- .../common/jwt/RefreshTokenService.java | 4 +- .../handler/CustomOAuth2SuccessHandler.java | 50 +++-- .../gitdeun/repo/dto/RepoResponseDto.java | 1 - .../teamEWSN/gitdeun/repo/entity/Repo.java | 7 +- .../user/controller/AuthController.java | 2 +- .../controller/UserSettingController.java | 6 +- .../teamEWSN/gitdeun/user/entity/Role.java | 2 +- .../teamEWSN/gitdeun/user/entity/User.java | 6 + .../gitdeun/user/service/AuthService.java | 6 +- .../gitdeun/user/service/UserService.java | 22 +++ src/main/resources/application-dev.yml | 2 +- src/main/resources/application.yml | 3 +- 18 files changed, 231 insertions(+), 96 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/config/SchedulingConfig.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SchedulingConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SchedulingConfig.java new file mode 100644 index 0000000..9590aee --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java index aa72761..9581677 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -5,18 +5,19 @@ public class SecurityPath { // permitAll public static final String[] PUBLIC_ENDPOINTS = { - "/api/signup", - "/api/login", "/api/token/refresh", - "/api/users/check-duplicate", - "/" + "/api/auth/oauth/refresh/*", + "/", + }; // hasRole("USER") public static final String[] USER_ENDPOINTS = { + "/api/auth/connect/github/state", "/api/users/me", "/api/users/me/**", - "/api/logout" + "/api/logout", + "/api/repos/**" }; // hasRole("ADMIN") diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java index ff5d85f..bb0553c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java @@ -9,7 +9,6 @@ public class AnalysisResultDto { // FastAPI가 반환하는 Repo 관련 정보 private String defaultBranch; - private String language; private String description; private LocalDateTime githubLastUpdatedAt; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java index 66a7849..0ca4da4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java @@ -11,4 +11,13 @@ public class JwtToken { private String grantType; private String accessToken; private String refreshToken; + + // 정적 메서드 + public static JwtToken of(String accessToken, String refreshToken) { + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java index 83e2de5..281e51b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java @@ -1,8 +1,8 @@ package com.teamEWSN.gitdeun.common.jwt; -import com.teamEWSN.gitdeun.common.exception.GlobalException; -import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.user.entity.Role; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.service.UserService; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import lombok.Getter; @@ -11,11 +11,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; -import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.UUID; @@ -27,6 +27,9 @@ public class JwtTokenProvider { private final SecretKey secretKey; + @Autowired + private UserService userService; + @Autowired private RefreshTokenService refreshTokenService; @@ -48,76 +51,139 @@ public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) { this.secretKey = Keys.hmacShaKeyFor(keyBytes); } - // 토큰 생성 - 유저 정보 이용 public JwtToken generateToken(Authentication authentication) { + Object principal = authentication.getPrincipal(); + + Long userId; + Role role; + + switch (principal) { + case CustomUserPrincipal p -> { + userId = p.getId(); + role = Role.valueOf(p.getRole()); + } + case OidcUser oidc -> { + userId = userService.upsertAndGetId( + oidc.getEmail(), oidc.getFullName(), oidc.getPicture(), oidc.getFullName()); + role = Role.USER; + } + case OAuth2User oauth2 -> { + String email = (String) oauth2.getAttributes().get("email"); + userId = userService.upsertAndGetId( + email, (String) oauth2.getAttributes().get("name"), + (String) oauth2.getAttributes().get("avatar_url"), (String) oauth2.getAttributes().get("login")); + role = Role.USER; + } + case null, default -> throw new IllegalStateException("Unsupported principal"); + } - long now = (new Date()).getTime(); - Date accessTokenExpiration = new Date(now + accessTokenExpired * 1000); - - CustomUserPrincipal userPrincipal = (CustomUserPrincipal) authentication.getPrincipal(); - - Long userId = ((CustomUserDetails) userPrincipal).getId(); + long now = System.currentTimeMillis(); + Date exp = new Date(now + accessTokenExpired * 1000); String jti = UUID.randomUUID().toString(); - // Access Token 생성 + String accessToken = Jwts.builder() - .subject(String.valueOf(userId)) // Subject를 불변값인 userId로 설정 - .issuedAt(new Date()) // 발행 시간 - .id(jti) // blacklist 관리를 위한 jwt token id - .claim("email", userPrincipal.getEmail()) // 이메일 - .claim("nickname", userPrincipal.getNickname()) // 닉네임 - .claim("role", userPrincipal.getRole()) // 사용자 역할(Role) - .claim("name",userPrincipal.getName()) - .claim("profileImage", userPrincipal.getProfileImage()) // 프로필 이미지 추가 - .expiration(accessTokenExpiration) // 만료 시간 - .signWith(secretKey) // 서명 + .subject(String.valueOf(userId)) + .issuedAt(new Date(now)) + .id(jti) + .claim("role", role.name()) + .expiration(exp) + .signWith(secretKey) .compact(); - // Refresh Token 생성 (임의의 값 생성) String refreshToken = UUID.randomUUID().toString(); + refreshTokenService.saveRefreshToken(refreshToken, userId, refreshTokenExpired); - // Redis에 Refresh Token 정보 저장 - refreshTokenService.saveRefreshToken( refreshToken, userPrincipal.getEmail(), refreshTokenExpired); - - - // JWT Token 객체 반환 - return JwtToken.builder() - .grantType("Bearer") - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - + return JwtToken.of(accessToken, refreshToken); } + // DB 조회 후 UserDetails 생성 + public Authentication getAuthentication(String token) { + Claims claims = jwtTokenParser.parseClaims(token); + Long userId = Long.valueOf(claims.getSubject()); + Role role = Role.valueOf(claims.get("role", String.class)); - // 토큰에서 유저 정보 추출 - public Authentication getAuthentication(String accessToken) { - // 토큰에서 Claims 추출 - Claims claims = jwtTokenParser.parseClaims(accessToken); - - // 권한 정보 확인 - if (claims.get("role") == null) { - throw new GlobalException(ErrorCode.ROLE_NOT_FOUND); - } - - // 클레임에서 모든 사용자 정보 추출 - Long id = Long.parseLong(claims.getSubject()); - String email = claims.get("email", String.class); - String nickname = claims.get("nickname", String.class); - String name = claims.get("name", String.class); - String profileImage = claims.get("profileImage", String.class); - Role role = Role.valueOf(claims.get("role", String.class)); + User user = userService.findById(userId); - CustomUserDetails userDetails = new CustomUserDetails(id, email, nickname, profileImage, role, name); - - Collection authorities = - Collections.singletonList(role::name); - - // Authentication 객체 반환 - return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + CustomUserDetails userDetails = + new CustomUserDetails(user.getId(), user.getEmail(), + user.getNickname(), user.getProfileImage(), + role, user.getName()); + return new UsernamePasswordAuthenticationToken( + userDetails, null, Collections.singletonList(role::name)); } +// // 토큰 생성 - 유저 정보 이용 +// public JwtToken generateToken(Authentication authentication) { +// +// long now = (new Date()).getTime(); +// Date accessTokenExpiration = new Date(now + accessTokenExpired * 1000); +// +// CustomUserPrincipal userPrincipal = (CustomUserPrincipal) authentication.getPrincipal(); +// +// Long userId = ((CustomUserDetails) userPrincipal).getId(); +// +// String jti = UUID.randomUUID().toString(); +// // Access Token 생성 +// String accessToken = Jwts.builder() +// .subject(String.valueOf(userId)) // Subject를 불변값인 userId로 설정 +// .issuedAt(new Date()) // 발행 시간 +// .id(jti) // blacklist 관리를 위한 jwt token id +// .claim("email", userPrincipal.getEmail()) // 이메일 +// .claim("nickname", userPrincipal.getNickname()) // 닉네임 +// .claim("role", userPrincipal.getRole()) // 사용자 역할(Role) +// .claim("name",userPrincipal.getName()) +// .claim("profileImage", userPrincipal.getProfileImage()) // 프로필 이미지 추가 +// .expiration(accessTokenExpiration) // 만료 시간 +// .signWith(secretKey) // 서명 +// .compact(); +// +// // Refresh Token 생성 (임의의 값 생성) +// String refreshToken = UUID.randomUUID().toString(); +// +// // Redis에 Refresh Token 정보 저장 +// refreshTokenService.saveRefreshToken( refreshToken, userPrincipal.getEmail(), refreshTokenExpired); +// +// +// // JWT Token 객체 반환 +// return JwtToken.builder() +// .grantType("Bearer") +// .accessToken(accessToken) +// .refreshToken(refreshToken) +// .build(); +// +// } +// +// +// // 토큰에서 유저 정보 추출 +// public Authentication getAuthentication(String accessToken) { +// // 토큰에서 Claims 추출 +// Claims claims = jwtTokenParser.parseClaims(accessToken); +// +// // 권한 정보 확인 +// if (claims.get("role") == null) { +// throw new GlobalException(ErrorCode.ROLE_NOT_FOUND); +// } +// +// // 클레임에서 모든 사용자 정보 추출 +// Long id = Long.parseLong(claims.getSubject()); +// String email = claims.get("email", String.class); +// String nickname = claims.get("nickname", String.class); +// String name = claims.get("name", String.class); +// String profileImage = claims.get("profileImage", String.class); +// Role role = Role.valueOf(claims.get("role", String.class)); +// +// CustomUserDetails userDetails = new CustomUserDetails(id, email, nickname, profileImage, role, name); +// +// Collection authorities = +// Collections.singletonList(role::name); +// +// // Authentication 객체 반환 +// return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); +// +// } + // 토큰 정보 검증 public boolean validateToken(String token) { log.debug("validateToken start"); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java index a2ca41f..e43edea 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java @@ -7,6 +7,7 @@ import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; @Getter @AllArgsConstructor @@ -18,7 +19,8 @@ public class RefreshToken { @Id private String refreshToken; - private String email; + @Indexed + private Long userId; private Long issuedAt; // Time to live (TTL) 설정, Redis에 만료 시간을 설정 diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java index 51e4818..f8b020f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java @@ -13,10 +13,10 @@ public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; - public void saveRefreshToken(String refreshToken, String email, long refreshTokenExpired) { + public void saveRefreshToken(String refreshToken, Long userId, long refreshTokenExpired) { RefreshToken token = RefreshToken.builder() .refreshToken(refreshToken) - .email(email) + .userId(userId) .issuedAt(System.currentTimeMillis()) .ttl(refreshTokenExpired) // @TimeToLive에 사용될 만료 시간 .build(); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java index d96969f..912d06b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java @@ -11,10 +11,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; @@ -32,23 +32,51 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa private String frontUrl; @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication; - OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); - String state = request.getParameter("state"); + public void onAuthenticationSuccess(HttpServletRequest req, + HttpServletResponse res, + Authentication auth) throws IOException { - String purpose = oAuthStateService.consumeState(state); // "connect:42" 또는 null + String state = req.getParameter("state"); + String purpose = state != null ? oAuthStateService.consumeState(state) : null; + + // 1) 계정 연동 시나리오 if (purpose != null && purpose.startsWith("connect:")) { - Long userId = Long.parseLong(purpose.split(":")[1]); - authService.connectGithubAccount(oAuth2User, userId); - response.sendRedirect(frontUrl + "/oauth/callback#connected=true"); + handleAccountConnection(purpose, (OAuth2User) auth.getPrincipal(), res); return; } - // 일반 로그인 흐름 + // 2) 일반 로그인 + handleStandardLogin(req, res, auth); + } + + /** + * 일반 로그인 성공 시 JWT 토큰을 발급 및 클라이언트로 리디렉션 + */ + private void handleStandardLogin(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + // JWT 액세스 토큰과 리프레시 토큰을 생성합니다. JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); + + // 리프레시 토큰은 보안을 위해 HttpOnly 쿠키에 저장합니다. cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); - String targetUrl = frontUrl + "/oauth/callback#accessToken=" + jwtToken.getAccessToken(); + + // 액세스 토큰은 URL 프래그먼트로 프론트엔드에 전달합니다. + String targetUrl = UriComponentsBuilder.fromUriString(frontUrl + "/oauth/callback") + .fragment("accessToken=" + jwtToken.getAccessToken()) + .build() + .toUriString(); + + clearAuthenticationAttributes(request); // 세션 클린업 getRedirectStrategy().sendRedirect(request, response, targetUrl); } + + /** + * 기존 계정에 새로운 소셜 계정을 연동하는 흐름을 처리 + */ + private void handleAccountConnection(String purpose, OAuth2User oAuth2User, HttpServletResponse response) throws IOException { + Long userId = Long.parseLong(purpose.split(":")[1]); + authService.connectGithubAccount(oAuth2User, userId); // 계정 연동 로직 호출 + + String targetUrl = frontUrl + "/oauth/callback#connected=true"; + response.sendRedirect(targetUrl); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java index 870c276..d2eddd1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java @@ -12,7 +12,6 @@ public class RepoResponseDto { private final Long repoId; private final String githubRepoUrl; private final String defaultBranch; - private final String language; private final String description; private final LocalDateTime githubLastUpdatedAt; diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index 8f0be06..83f4000 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -24,9 +24,6 @@ public class Repo { @Column(name = "default_branch", length = 100) private String defaultBranch; // 기본 브랜치 - @Column(length = 50) - private String language; // 주요 언어 - @Column(columnDefinition = "TEXT") private String description; // 설명 @@ -35,16 +32,14 @@ public class Repo { @Builder - public Repo(String githubRepoUrl, String defaultBranch, String language, String description, LocalDateTime githubLastUpdatedAt) { + public Repo(String githubRepoUrl, String defaultBranch, String description, LocalDateTime githubLastUpdatedAt) { this.githubRepoUrl = githubRepoUrl; this.defaultBranch = defaultBranch; - this.language = language; this.description = description; this.githubLastUpdatedAt = githubLastUpdatedAt; } public void updateWithAnalysis(AnalysisResultDto result) { - this.language = result.getLanguage(); this.description = result.getDescription(); this.githubLastUpdatedAt = result.getGithubLastUpdatedAt(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index 2f8954a..4ad229b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -59,7 +59,7 @@ public ResponseEntity logout( } // 토큰 재발급 - @PostMapping("/token/refresh") + @GetMapping("/token/refresh") public ResponseEntity refreshAccessToken( @CookieValue(name = "refreshToken") String refreshToken, HttpServletResponse response) { diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java index 8c26416..e865b54 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java @@ -19,7 +19,7 @@ public class UserSettingController { private final UserSettingService userSettingService; /** - * 현재 로그인된 사용자의 설정을 조회합니다. + * 현재 로그인된 사용자의 설정을 조회 * @param userDetails 인증된 사용자 정보 * @return 현재 설정 정보를 담은 응답 */ @@ -32,12 +32,12 @@ public ResponseEntity getUserSettings( } /** - * 현재 로그인된 사용자의 설정을 변경합니다. + * 현재 로그인된 사용자의 설정을 변경 * @param userDetails 인증된 사용자 정보 * @param requestDto 변경할 설정 정보를 담은 요청 DTO * @return 변경된 설정 정보를 담은 응답 */ - @PatchMapping // 리소스의 일부만 변경하므로 PATCH가 더 적합합니다. + @PatchMapping public ResponseEntity updateUserSettings( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody UserSettingUpdateRequestDto requestDto diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java index be3d139..bee52db 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java @@ -4,7 +4,7 @@ public enum Role implements GrantedAuthority { ROLE_USER, - ROLE_ADMIN; + ROLE_ADMIN, USER; @Override public String getAuthority() { diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 0d0c532..7cb2495 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -50,6 +50,12 @@ public User(String name, String nickname, String email, String profileImage, Rol this.role = role; } + public User updateProfile(String name, String profileImage) { + this.name = name; + this.profileImage = profileImage; + return this; // 메소드 체이닝을 위해 this 반환 + } + // 회원 탈퇴 처리 public void markAsDeleted() { this.deletedAt = LocalDateTime.now(); diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index 4543ca7..b9d30ab 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -65,10 +65,10 @@ public JwtToken refreshTokens(String refreshToken) { .orElseThrow(() -> new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN)); // 토큰에서 email 정보 추출 - String email = tokenDetails.getEmail(); + Long userId = tokenDetails.getUserId(); - // email로 사용자 정보 조회 - User user = userService.findUserByEmail(email); + // userId로 사용자 정보 조회 + User user = userService.findById(userId); Authentication authentication = createAuthentication(user); // 기존 리프레시 토큰은 DB에서 제거 (순환) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java index 9e2cd66..133620d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java @@ -8,6 +8,7 @@ import com.teamEWSN.gitdeun.common.oauth.service.GoogleApiHelper; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.entity.Role; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.mapper.UserMapper; import com.teamEWSN.gitdeun.user.repository.UserRepository; @@ -87,4 +88,25 @@ public User findUserByEmail(String email) { .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_EMAIL)); } + + // 아이디로 회원 검색 + @Transactional(readOnly = true) + public User findById(Long id) { + return userRepository.findByIdAndDeletedAtIsNull(id) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + } + + @Transactional + public Long upsertAndGetId(String email, String name, String picture, String nickname) { + return userRepository.findByEmailAndDeletedAtIsNull(email) + .map(u -> u.updateProfile(name, picture)) // 이미 있으면 갱신 + .orElseGet(() -> userRepository.save( + User.builder() + .email(email).name(name).profileImage(picture) + .nickname(nickname) + .role(Role.USER) + .build())) + .getId(); + } } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 8fa6f61..f8f3507 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -9,7 +9,7 @@ spring: client: registration: google: - redirect-uri: http://localhost:8080/login/oauth2/code/google # /oauth2/authorization/google 로그인 시작 URL + redirect-uri: http://localhost:8080/login/oauth2/code/google github: client-id: ${GITHUB_DEV_CLIENT_ID} client-secret: ${GITHUB_DEV_CLIENT_SECRET} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c7fd4e8..fff3e2f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,7 +19,6 @@ spring: ddl-auto: create # create # update properties: hibernate: - dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true # SQL 로그를 보기 좋게 포맷 auto_quote_keyword: true # 예약어를 자동으로 따옴표 처리 order_inserts: true @@ -33,7 +32,7 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - scope: profile, email + scope: openid, email, profile github: scope: user:email, repo profiles: From 4a5b20dbd57284623a9511a5821e005e403b9460 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Tue, 29 Jul 2025 20:40:52 +0900 Subject: [PATCH 39/60] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B8=EB=93=9C?= =?UTF-8?q?=EB=A7=B5=20=EC=83=9D=EC=84=B1=EA=B3=BC=20repo=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=93=B1=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/fastapi/FastApiClient.java | 34 ++++++ .../common/fastapi/dto/AnalysisResultDto.java | 2 +- .../mindmap/controller/MindmapController.java | 31 +++++- .../mindmap/dto/MindmapCreateRequest.java | 15 +++ .../gitdeun/mindmap/dto/MindmapDto.java | 5 - .../mindmap/dto/MindmapResponseDto.java | 19 ++++ .../gitdeun/mindmap/entity/Mindmap.java | 10 +- .../gitdeun/mindmap/mapper/MindmapMapper.java | 4 + .../mindmap/repository/MindmapRepository.java | 16 ++- .../mindmap/service/MindmapService.java | 102 +++++++++++++++++- 10 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index 894e902..c34e86c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -1,9 +1,14 @@ package com.teamEWSN.gitdeun.common.fastapi; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.common.fastapi.dto.FastApiCommitTimeResponse; +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.AllArgsConstructor; +import lombok.Getter; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; import java.time.LocalDateTime; @@ -16,6 +21,26 @@ public FastApiClient(@Qualifier("fastApiWebClient") WebClient webClient) { this.webClient = webClient; } + /** + * FastAPI 서버에 리포지토리 분석을 요청하고 그 결과를 받아옵니다. + * @param repoUrl 분석할 리포지토리의 URL + * @param prompt 분석에 사용할 프롬프트 + * @param type 분석 타입 (DEV, CHECK) + * @return 분석 결과 DTO + */ + public AnalysisResultDto analyze(String repoUrl, String prompt, MindmapType type) { + // FastAPI 요청 본문을 위한 내부 DTO + AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt, type); + + return webClient.post() + .uri("/analyze") // FastAPI에 정의된 분석 엔드포인트 + .body(Mono.just(requestBody), AnalysisRequest.class) + .retrieve() // 응답을 받아옴 + .bodyToMono(AnalysisResultDto.class) // 응답 본문을 DTO로 변환 + .block(); // 비동기 처리를 동기적으로 대기 + } + + /** * FastAPI 서버에 특정 GitHub 리포지토리의 최신 커밋 시간을 요청합니다. * @param githubRepoUrl 조회할 리포지토리의 URL @@ -41,4 +66,13 @@ public LocalDateTime fetchLatestCommitTime(String githubRepoUrl) { // TODO: requestAnalysis 등 다른 FastAPI 호출 메서드들도 여기에 구현 + + + @Getter + @AllArgsConstructor + private static class AnalysisRequest { + private String url; + private String prompt; + private MindmapType type; + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java index bb0553c..eef7d40 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java @@ -13,7 +13,7 @@ public class AnalysisResultDto { private LocalDateTime githubLastUpdatedAt; // FastAPI가 반환하는 Mindmap 관련 정보 - private String mapData; + private String mapData; // JSON 형태의 마인드맵 데이터 private MindmapType type; private String prompt; // TODO: FastAPI 응답에 맞춰 필드 정의 diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index dc7cfd5..32129dc 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,8 +1,37 @@ package com.teamEWSN.gitdeun.mindmap.controller; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequest; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.service.MindmapService; +import com.teamEWSN.gitdeun.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor public class MindmapController { + private final MindmapService mindmapService; + + + @PostMapping + public ResponseEntity createMindmap( + @RequestBody MindmapCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + MindmapResponseDto responseDto = mindmapService.createMindmap(request, userDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } + - // TODO: 마인드맵 로딩 시 자동 확인 + 새로고침 시 재동기화 + // TODO: 마인드맵 방문 시 / 새로고침 시 업뎃 자동 확인 + 재동기화 } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java new file mode 100644 index 0000000..10bbf93 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MindmapCreateRequest { + private String repoUrl; + private String prompt; + private MindmapType type; + + private String title; // Optional, 'CHECK' 타입일 때 사용자가 입력하는 제목 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDto.java deleted file mode 100644 index b22ab69..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmap.dto; - -public class MindmapDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java new file mode 100644 index 0000000..c9c3986 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class MindmapResponseDto { + private Long id; + private Long repoId; + private MindmapType type; + private String field; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index d26a8a3..0acc240 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -4,16 +4,15 @@ import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.ColumnDefault; +import lombok.*; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@NoArgsConstructor +@AllArgsConstructor @Table(name = "Mindmap") public class Mindmap extends AuditedEntity { @@ -41,7 +40,6 @@ public class Mindmap extends AuditedEntity { private MindmapType type; @Column(name = "Field", length = 255, nullable = false) - @ColumnDefault("'확인용 (n)'") private String field; @JdbcTypeCode(SqlTypes.JSON) diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java index 3b027ba..a2c0caa 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java @@ -1,8 +1,12 @@ package com.teamEWSN.gitdeun.mindmap.mapper; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import org.mapstruct.Mapper; import org.mapstruct.ReportingPolicy; @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface MindmapMapper { + + MindmapResponseDto toResponseDto(Mindmap mindmap); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java index 4e26d5f..d2ebb57 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java @@ -1,10 +1,24 @@ package com.teamEWSN.gitdeun.mindmap.repository; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface MindmapRepository extends JpaRepository { - + + // 사용자가 생성한 확인용 마인드맵 중 가장 최근에 생성된 것(repo 무관) + @Query("SELECT m FROM Mindmap m " + + "WHERE m.user = :user AND m.type = 'CHECK' " + + "ORDER BY m.createdAt DESC LIMIT 1") + Optional findTopByUserAndTypeOrderByCreatedAtDesc( + @Param("user") User user + ); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 280bcc2..2077476 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -1,5 +1,105 @@ package com.teamEWSN.gitdeun.mindmap.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequest; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import com.teamEWSN.gitdeun.mindmap.mapper.MindmapMapper; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.repo.repository.RepoRepository; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Service +@RequiredArgsConstructor public class MindmapService { - + + private final MindmapMapper mindmapMapper; + private final MindmapRepository mindmapRepository; + private final RepoRepository repoRepository; + private final UserRepository userRepository; + private final FastApiClient fastApiClient; + + @Transactional + public MindmapResponseDto createMindmap(MindmapCreateRequest req, Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Repo repo = repoRepository.findByGithubRepoUrl(req.getRepoUrl()) + .orElseGet(() -> repoRepository.save(Repo.builder().githubRepoUrl(req.getRepoUrl()).build())); + + AnalysisResultDto dto = fastApiClient.analyze(req.getRepoUrl(), req.getPrompt(), req.getType()); + + repo.updateWithAnalysis(dto); + repoRepository.save(repo); // dirty-checking + + String field; + + if (req.getType() == MindmapType.DEV) { + field = "개발용"; + } else { + if (req.getTitle() != null && !req.getTitle().isEmpty()) { + field = req.getTitle(); + } else { + // findNextCheckSequence 호출 시 repo 정보 제거 + long nextSeq = findNextCheckSequence(user); + field = "확인용 (" + nextSeq + ")"; + } + } + + Mindmap mindmap = Mindmap.builder() + .repo(repo) + .user(user) + .prompt(req.getPrompt()) + .branch(dto.getDefaultBranch()) + .type(req.getType()) + .field(field) + .mapData(dto.getMapData()) + .build(); + + mindmapRepository.save(mindmap); + + return mindmapMapper.toResponseDto(mindmap); + + } + + /** + * 특정 사용자의 "확인용 (n)" 다음 시퀀스 번호를 찾습니다. + * @param user 대상 사용자 + * @return 다음 시퀀스 번호 + */ + private long findNextCheckSequence(User user) { + // repo 조건이 제거된 리포지토리 메서드 호출 + Optional lastCheckMindmap = mindmapRepository.findTopByUserAndTypeOrderByCreatedAtDesc(user); + + if (lastCheckMindmap.isEmpty()) { + return 1; + } + + Pattern pattern = Pattern.compile("\\((\\d+)\\)"); + Matcher matcher = pattern.matcher(lastCheckMindmap.get().getField()); + + if (matcher.find()) { + long lastSeq = Long.parseLong(matcher.group(1)); + return lastSeq + 1; + } + + return 1; + } + + } \ No newline at end of file From 609aaf7cede07d2cae1adf44b0957203a2f45196 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 30 Jul 2025 05:12:36 +0900 Subject: [PATCH 40/60] =?UTF-8?q?feat:=20entity=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EB=AA=85=20=EC=8A=A4=EB=84=A4=EC=9D=B4=ED=81=AC=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=80=EA=B2=BD,=20PinnedHisto?= =?UTF-8?q?ry=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/codereview/entity/CodeReview.java | 2 +- .../gitdeun/comment/entity/Comment.java | 2 +- .../comment/entity/CommentAttachment.java | 2 +- .../gitdeun/invitation/entity/Invitation.java | 2 +- .../gitdeun/meeting/entity/Meeting.java | 2 +- .../gitdeun/meeting/entity/Participant.java | 2 +- .../gitdeun/mindmap/entity/Mindmap.java | 2 +- .../mindmapedge/entity/MindmapEdge.java | 2 +- .../mindmapnode/entity/MindmapNode.java | 2 +- .../teamEWSN/gitdeun/repo/entity/Repo.java | 2 +- .../visithistory/entity/PinnedHistory.java | 31 +++++++++++++++++++ .../visithistory/entity/VisitHistory.java | 24 ++++++++------ .../visithistory/entity/VisitHistoryId.java | 13 -------- 13 files changed, 55 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java delete mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java index 84b1779..ab58460 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java @@ -12,7 +12,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Code_Review") +@Table(name = "code_review") public class CodeReview extends AuditedEntity { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java index 72e028c..fc833b5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java @@ -13,7 +13,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Comment") +@Table(name = "comment") public class Comment extends AuditedEntity { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java index 42c380a..61985d9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java @@ -8,7 +8,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Comment_Attachment") +@Table(name = "comment_attachment") public class CommentAttachment { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java index 9879940..40cceac 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java @@ -14,7 +14,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Invitation") +@Table(name = "invitation") public class Invitation { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java index 1f1676c..7f58fe4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java @@ -13,7 +13,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Meeting") +@Table(name = "meeting") public class Meeting extends CreatedEntity { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java index 65ccea3..bcb8b40 100644 --- a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java @@ -12,7 +12,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Participant") +@Table(name = "participant") @IdClass(ParticipantId.class) public class Participant { diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index 0acc240..6044500 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -13,7 +13,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -@Table(name = "Mindmap") +@Table(name = "mindmap") public class Mindmap extends AuditedEntity { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java index 17a8409..8b96998 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java @@ -13,7 +13,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Mindmap_Edge") +@Table(name = "mindmap_edge") public class MindmapEdge extends AuditedEntity { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java index 59b15c3..73a946c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java @@ -11,7 +11,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Mindmap_Node") +@Table(name = "mindmap_node") public class MindmapNode extends AuditedEntity { @Id diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index 83f4000..588272a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -12,7 +12,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Repo") +@Table(name = "repo") public class Repo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java new file mode 100644 index 0000000..1a86b4e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java @@ -0,0 +1,31 @@ +package com.teamEWSN.gitdeun.visithistory.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "pinned_history") +public class PinnedHistory extends CreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @JsonIgnore + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "visit_history_id", nullable = false) + private VisitHistory visitHistory; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java index 3292b63..3fcbf89 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java @@ -3,26 +3,29 @@ import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import org.hibernate.annotations.ColumnDefault; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "Visit_History") -@IdClass(VisitHistoryId.class) +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "visit_history") public class VisitHistory { @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; - @Id @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "mindmap_id", nullable = false) private Mindmap mindmap; @@ -30,7 +33,8 @@ public class VisitHistory { @Column(name = "last_visited_at", nullable = false) private LocalDateTime lastVisitedAt; - @Column(nullable = false) - @ColumnDefault("false") - private boolean pinned; + @Builder.Default + @OneToMany(mappedBy = "visit_history", cascade = CascadeType.ALL, orphanRemoval = true) + private List pinnedHistorys = new ArrayList<>(); + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java deleted file mode 100644 index 40b0755..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistoryId.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.teamEWSN.gitdeun.visithistory.entity; - -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -import java.io.Serializable; - -@NoArgsConstructor -@EqualsAndHashCode -public class VisitHistoryId implements Serializable { - private Long user; - private Long mindmap; -} From cf0435abbf1c2d744cc8d6a640af4eb2460ff043 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 30 Jul 2025 05:37:02 +0900 Subject: [PATCH 41/60] =?UTF-8?q?refactor:=20Dto=EC=9D=98=20id=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EA=B5=AC=EC=B2=B4=ED=99=94,=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EB=B0=8F=20import=20=EC=A0=95=EB=A6=AC,=20@Mapping=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=88=98=EC=A0=95,=20MindmapDetailRespons?= =?UTF-8?q?eDto=20=EC=83=9D=EC=84=B1,=20=E3=85=A1mindmapRepository=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 --- .../gitdeun/common/exception/ErrorCode.java | 3 +++ .../mindmap/dto/MindmapCreateRequest.java | 2 +- .../mindmap/dto/MindmapDetailResponseDto.java | 22 +++++++++++++++++++ .../mindmap/dto/MindmapResponseDto.java | 2 +- .../gitdeun/mindmap/entity/Mindmap.java | 3 +++ .../gitdeun/mindmap/mapper/MindmapMapper.java | 6 +++++ .../mindmap/repository/MindmapRepository.java | 4 +--- .../teamEWSN/gitdeun/repo/entity/Repo.java | 1 + .../gitdeun/repo/mapper/RepoMapper.java | 2 ++ .../user/controller/UserController.java | 1 - .../gitdeun/user/dto/UserResponseDto.java | 2 +- .../gitdeun/user/mapper/UserMapper.java | 3 ++- .../gitdeun/user/service/AuthService.java | 14 ------------ 13 files changed, 43 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 05d382d..eba4a9e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -38,6 +38,9 @@ public enum ErrorCode { REPO_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "REPO-001", "해당 ID로 요청한 리포지토리를 찾을 수 없습니다."), REPO_NOT_FOUND_BY_URL(HttpStatus.NOT_FOUND, "REPO-002", "해당 URL로 요청한 리포지토리를 찾을 수 없습니다."), + // 마인드맵 관련 + MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "요청한 마인드맵을 찾을 수 없습니다."), + // S3 파일 관련 // Client Errors (4xx) FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java index 10bbf93..aca2939 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java @@ -11,5 +11,5 @@ public class MindmapCreateRequest { private String prompt; private MindmapType type; - private String title; // Optional, 'CHECK' 타입일 때 사용자가 입력하는 제목 + private String field; // Optional, 'CHECK' 타입일 때 사용자가 입력하는 제목 } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java new file mode 100644 index 0000000..940e119 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class MindmapDetailResponseDto { + private Long mindmapId; + private String field; // 제목 ("개발용", "확인용(n)" 등) + private MindmapType type; + private String branch; + private String prompt; + private String mapData; // 핵심 데이터인 마인드맵 JSON + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java index c9c3986..c47e190 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java @@ -11,7 +11,7 @@ @Builder @AllArgsConstructor public class MindmapResponseDto { - private Long id; + private Long mindmapId; private Long repoId; private MindmapType type; private String field; diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index 6044500..31eb2a8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -46,4 +46,7 @@ public class Mindmap extends AuditedEntity { @Column(name = "map_data", columnDefinition = "json", nullable = false) private String mapData; + public void updateMapData(String newMapData) { + this.mapData = newMapData; + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java index a2c0caa..1bc8429 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java @@ -1,12 +1,18 @@ package com.teamEWSN.gitdeun.mindmap.mapper; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface MindmapMapper { + @Mapping(source = "id", target = "mindmapId") MindmapResponseDto toResponseDto(Mindmap mindmap); + + @Mapping(source = "id", target = "mindmapId") + MindmapDetailResponseDto toDetailResponseDto(Mindmap mindmap); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java index d2ebb57..649338b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java @@ -1,8 +1,6 @@ package com.teamEWSN.gitdeun.mindmap.repository; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; -import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -12,7 +10,7 @@ import java.util.Optional; @Repository -public interface MindmapRepository extends JpaRepository { +public interface MindmapRepository extends JpaRepository { // 사용자가 생성한 확인용 마인드맵 중 가장 최근에 생성된 것(repo 무관) @Query("SELECT m FROM Mindmap m " + diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index 588272a..7b3fb71 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -40,6 +40,7 @@ public Repo(String githubRepoUrl, String defaultBranch, String description, Loca } public void updateWithAnalysis(AnalysisResultDto result) { + this.defaultBranch = result.getDefaultBranch(); this.description = result.getDescription(); this.githubLastUpdatedAt = result.getGithubLastUpdatedAt(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java b/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java index fe6db82..d4f6151 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java @@ -3,11 +3,13 @@ import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; import com.teamEWSN.gitdeun.repo.entity.Repo; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface RepoMapper { + @Mapping(source = "id", target = "repoId") RepoResponseDto toResponseDto(Repo repo); } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java index 130ed5c..47d7c70 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java @@ -19,7 +19,6 @@ public class UserController { private final UserService userService; - private final CustomOAuth2UserService customOAuth2UserService; // 개인 정보 조회 @GetMapping diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java index cdf4d13..0d41235 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java @@ -8,7 +8,7 @@ @Getter @Builder public class UserResponseDto { - private Long id; // 사용자 ID + private Long userId; // 사용자 ID private String name; // 사용자 이름 private String email; // 이메일 private String nickname; // 닉네임 diff --git a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java index a9672d7..b3939e8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java @@ -3,12 +3,13 @@ import com.teamEWSN.gitdeun.user.dto.UserResponseDto; import com.teamEWSN.gitdeun.user.entity.User; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface UserMapper { - + @Mapping(source = "id", target = "userId") UserResponseDto toResponseDto(User user); } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index b9d30ab..24fd669 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -13,7 +13,6 @@ import com.teamEWSN.gitdeun.user.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -37,18 +36,6 @@ public class AuthService { private final UserRepository userRepository; private final SocialConnectionRepository socialConnectionRepository; - @Value("${jwt.refresh-expired}") - private Long refreshTokenExpired; - - @Value("${spring.security.oauth2.client.registration.github.client-id}") - private String githubClientId; - - @Value("${spring.security.oauth2.client.registration.github.client-secret}") - private String githubClientSecret; - - @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") - private String githubRedirectUri; - // 로그 아웃 @Transactional public void logout(String accessToken, String refreshToken, HttpServletResponse response) { @@ -103,7 +90,6 @@ public void connectGithubAccount(OAuth2User githubUser, Long userId) { throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); } else { log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); - return; } }); From cf61b7ba3864e517bb507db8177081c37fd00421 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 30 Jul 2025 06:03:29 +0900 Subject: [PATCH 42/60] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B8=EB=93=9C?= =?UTF-8?q?=EB=A7=B5=20=EB=B0=A9=EB=AC=B8=EC=A1=B0=ED=9A=8C,=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8,=20=EC=82=AD=EC=A0=9C=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 --- .../mindmap/controller/MindmapController.java | 36 ++++++++-- .../mindmap/service/MindmapService.java | 71 ++++++++++++++++--- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index 32129dc..b1ed9bb 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -2,17 +2,15 @@ import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequest; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; import com.teamEWSN.gitdeun.mindmap.service.MindmapService; -import com.teamEWSN.gitdeun.user.entity.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; 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.RestController; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -22,6 +20,7 @@ public class MindmapController { private final MindmapService mindmapService; + // 마인드맵 생성 @PostMapping public ResponseEntity createMindmap( @RequestBody MindmapCreateRequest request, @@ -31,6 +30,35 @@ public ResponseEntity createMindmap( return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } + // 마인드맵 상세 조회(유저 인가 확인필요) + @GetMapping("/{mapId}") + public ResponseEntity getMindmap( + @PathVariable Long mapId + ) { + MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId); + return ResponseEntity.ok(responseDto); + } + + // 마인드맵 새로고침 + @PostMapping("/{mapId}/refresh") + public ResponseEntity refreshMindmap( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + MindmapDetailResponseDto responseDto = mindmapService.refreshMindmap(mapId, userDetails.getId()); + return ResponseEntity.ok(responseDto); + } + + // 마인드맵 삭제 + @DeleteMapping("/{mapId}") + public ResponseEntity deleteMindmap( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + mindmapService.deleteMindmap(mapId, userDetails.getId()); + return ResponseEntity.ok().build(); // 성공 시 200 OK와 빈 body 반환 + } + // TODO: 마인드맵 방문 시 / 새로고침 시 업뎃 자동 확인 + 재동기화 diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 2077476..25408ae 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -5,6 +5,7 @@ import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequest; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; @@ -12,6 +13,7 @@ import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.repo.repository.RepoRepository; +import com.teamEWSN.gitdeun.repo.service.RepoService; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -27,7 +29,7 @@ @Service @RequiredArgsConstructor public class MindmapService { - + private final RepoService repoService; private final MindmapMapper mindmapMapper; private final MindmapRepository mindmapRepository; private final RepoRepository repoRepository; @@ -39,21 +41,20 @@ public MindmapResponseDto createMindmap(MindmapCreateRequest req, Long userId) { User user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - Repo repo = repoRepository.findByGithubRepoUrl(req.getRepoUrl()) - .orElseGet(() -> repoRepository.save(Repo.builder().githubRepoUrl(req.getRepoUrl()).build())); - AnalysisResultDto dto = fastApiClient.analyze(req.getRepoUrl(), req.getPrompt(), req.getType()); + Repo repo = repoRepository.findByGithubRepoUrl(req.getRepoUrl()) + .orElseGet(() -> Repo.builder().githubRepoUrl(req.getRepoUrl()).build()); + repo.updateWithAnalysis(dto); - repoRepository.save(repo); // dirty-checking + repoRepository.save(repo); String field; - if (req.getType() == MindmapType.DEV) { field = "개발용"; } else { - if (req.getTitle() != null && !req.getTitle().isEmpty()) { - field = req.getTitle(); + if (req.getField() != null && !req.getField().isEmpty()) { + field = req.getField(); } else { // findNextCheckSequence 호출 시 repo 정보 제거 long nextSeq = findNextCheckSequence(user); @@ -102,4 +103,58 @@ private long findNextCheckSequence(User user) { } + /** + * 마인드맵 상세 정보 조회 + */ + @Transactional + public MindmapDetailResponseDto getMindmap(Long mapId) { + Mindmap mindmap = mindmapRepository.findById(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + return mindmapMapper.toDetailResponseDto(mindmap); + } + + /** + * 마인드맵 새로고침 + */ + @Transactional + public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId) { + Mindmap mindmap = mindmapRepository.findById(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + // 마인드맵 생성자만 새로고침 가능 + if (!mindmap.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 기존 정보로 FastAPI 재호출 + AnalysisResultDto dto = fastApiClient.analyze( + mindmap.getRepo().getGithubRepoUrl(), + mindmap.getPrompt(), + mindmap.getType() + ); + + // 데이터 최신화 + mindmap.getRepo().updateWithAnalysis(dto); + mindmap.updateMapData(dto.getMapData()); // Mindmap 엔티티에 편의 메서드 추가 필요 + + return mindmapMapper.toDetailResponseDto(mindmap); + } + + /** + * 마인드맵 삭제 + */ + @Transactional + public void deleteMindmap(Long mapId, Long userId) { + Mindmap mindmap = mindmapRepository.findById(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + // 마인드맵 생성자만 삭제 가능 + if (!mindmap.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + mindmapRepository.delete(mindmap); + } + } \ No newline at end of file From f96075f60b0ca9b40961b090f4f3fe808cc8ec90 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Wed, 30 Jul 2025 06:07:40 +0900 Subject: [PATCH 43/60] =?UTF-8?q?fix:=20visitHistory=20mappedby=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=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 --- .../com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java index 3fcbf89..ef50958 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java @@ -34,7 +34,7 @@ public class VisitHistory { private LocalDateTime lastVisitedAt; @Builder.Default - @OneToMany(mappedBy = "visit_history", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "visitHistory", cascade = CascadeType.ALL, orphanRemoval = true) private List pinnedHistorys = new ArrayList<>(); } \ No newline at end of file From d0d0a188ebee5350bfc11e548756d2a32d3c36bc Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 31 Jul 2025 01:07:25 +0900 Subject: [PATCH 44/60] =?UTF-8?q?feat:=20=EB=B0=A9=EB=AC=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=95=80=20=EA=B3=A0=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/exception/ErrorCode.java | 8 +++ .../controller/PinnedHistoryController.java | 40 +++++++++++++ .../visithistory/entity/PinnedHistory.java | 13 ++++- .../repository/PinnedHistoryRepository.java | 20 +++++++ .../service/PinnedHistoryService.java | 57 +++++++++++++++++++ 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index eba4a9e..3586467 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -41,6 +41,14 @@ public enum ErrorCode { // 마인드맵 관련 MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "요청한 마인드맵을 찾을 수 없습니다."), + // 방문기록 관련 + HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."), + + // 방문 기록 핀 고정 관련 + USER_NOT_FOUND_FIX_PIN(HttpStatus.NOT_FOUND, "PINNEDHISTORY-001", "핀 고정한 유저를 찾을 수 없습니다."), + PINNEDHISTORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "PINNEDHISTORY-002", "이미 핀 고정한 기록입니다."), + PINNEDHISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PINNEDHISTORY-003", "핀 고정 기록을 찾을 수 없습니다."), + // S3 파일 관련 // Client Errors (4xx) FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java new file mode 100644 index 0000000..d21df1d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.visithistory.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.visithistory.service.PinnedHistoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/history/{historyId}/mindmaps/pinned") +@RequiredArgsConstructor +public class PinnedHistoryController { + + private final PinnedHistoryService pinnedHistoryService; + + // 핀 고정 + @PostMapping + public ResponseEntity fixPinned( + @PathVariable("historyId") Long historyId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + pinnedHistoryService.fixPinned(historyId, customUserDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + // 핀 해제 + @DeleteMapping + public ResponseEntity removePinned( + @PathVariable("historyId") Long historyId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + pinnedHistoryService.removePinned(historyId, customUserDetails.getId()); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java index 1a86b4e..a4c5402 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java @@ -2,10 +2,10 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.teamEWSN.gitdeun.common.util.CreatedEntity; -import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,7 +13,10 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "pinned_history") +@Table(name = "pinned_history", indexes = { + @Index(name = "idx_pinnedHistory_user_visit_history", columnList = "user_id, visit_history_id", unique = true), // 주요 조회 조건 및 중복 방지 + @Index(name = "idx_pinnedHistory_visit_history_id", columnList = "visit_history_id") // 방문 기록 기준 핀 고정 목록 조회 +}) public class PinnedHistory extends CreatedEntity { @Id @@ -28,4 +31,10 @@ public class PinnedHistory extends CreatedEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "visit_history_id", nullable = false) private VisitHistory visitHistory; + + @Builder + public PinnedHistory(User user, VisitHistory visitHistory) { + this.user = user; + this.visitHistory = visitHistory; + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java new file mode 100644 index 0000000..ff47d84 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.visithistory.repository; + +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PinnedHistoryRepository extends JpaRepository { + + boolean existsByUserIdAndVisitHistoryId(Long userId, Long historyId); + + Optional findByUserIdAndVisitHistoryId(Long userId, Long historyId); + + // 사용자의 핀 고정 기록 최신순 조회 + List findByUserOrderByCreatedAtDesc(User user); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java new file mode 100644 index 0000000..69c9df7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java @@ -0,0 +1,57 @@ +package com.teamEWSN.gitdeun.visithistory.service; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import com.teamEWSN.gitdeun.visithistory.repository.PinnedHistoryRepository; +import com.teamEWSN.gitdeun.visithistory.repository.VisitHistoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.teamEWSN.gitdeun.common.exception.ErrorCode.*; + + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class PinnedHistoryService { + + private final PinnedHistoryRepository pinnedHistoryRepository; + private final UserRepository userRepository; + private final VisitHistoryRepository visitHistoryRepository; + + public void fixPinned(Long historyId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(USER_NOT_FOUND_FIX_PIN)); + + VisitHistory visitHistory = visitHistoryRepository.findById(historyId) + .orElseThrow(() -> new GlobalException(HISTORY_NOT_FOUND)); + + // 이미 핀 고정이 있는지 확인 + if (pinnedHistoryRepository.existsByUserIdAndVisitHistoryId(userId, historyId)) { + throw new GlobalException(PINNEDHISTORY_ALREADY_EXISTS); + } + + PinnedHistory pin = PinnedHistory.builder() + .user(user) + .visitHistory(visitHistory) + .build(); + + pinnedHistoryRepository.save(pin); + + } + + @Transactional + public void removePinned(Long historyId, Long userId) { + PinnedHistory pin = pinnedHistoryRepository.findByUserIdAndVisitHistoryId(userId, historyId) + .orElseThrow(() -> new GlobalException(PINNEDHISTORY_NOT_FOUND)); + + pinnedHistoryRepository.delete(pin); + + } +} From 050ae80229bf267364dcc4fe267061254c00259d Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 31 Jul 2025 01:07:39 +0900 Subject: [PATCH 45/60] =?UTF-8?q?feat:=20=EB=B0=A9=EB=AC=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EC=83=9D=EC=84=B1,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/service/MindmapService.java | 7 +- .../controller/VisitHistoryController.java | 47 +++++++++++- .../visithistory/dto/VisitHistoryDto.java | 5 -- .../dto/VisitHistoryResponseDto.java | 16 ++++ .../mapper/VisitHistoryMapper.java | 18 +++++ .../repository/VisitHistoryRepository.java | 19 ++++- .../service/VisitHistoryService.java | 74 ++++++++++++++++++- 7 files changed, 175 insertions(+), 11 deletions(-) delete mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 25408ae..a88f024 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -13,9 +13,9 @@ import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.repo.repository.RepoRepository; -import com.teamEWSN.gitdeun.repo.service.RepoService; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -29,7 +29,7 @@ @Service @RequiredArgsConstructor public class MindmapService { - private final RepoService repoService; + private final VisitHistoryService visitHistoryService; private final MindmapMapper mindmapMapper; private final MindmapRepository mindmapRepository; private final RepoRepository repoRepository; @@ -74,6 +74,9 @@ public MindmapResponseDto createMindmap(MindmapCreateRequest req, Long userId) { mindmapRepository.save(mindmap); + // 방문 기록 생성 + visitHistoryService.createVisitHistory(user, mindmap); + return mindmapMapper.toResponseDto(mindmap); } diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java index b4628cd..260ee09 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java @@ -1,5 +1,50 @@ package com.teamEWSN.gitdeun.visithistory.controller; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; +import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api/history") +@RequiredArgsConstructor public class VisitHistoryController { - + + private final VisitHistoryService visitHistoryService; + + // 핀 고정되지 않은 방문 기록 조회 + @GetMapping("/visits") + public ResponseEntity> getVisitHistories( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List histories = visitHistoryService.getVisitHistories(userDetails.getId()); + return ResponseEntity.ok(histories); + } + + // 핀 고정된 방문 기록 조회 + @GetMapping("/pins") + public ResponseEntity> getPinnedHistories( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List histories = visitHistoryService.getPinnedHistories(userDetails.getId()); + return ResponseEntity.ok(histories); + } + + // 방문 기록 삭제 + @DeleteMapping("/visits/{historyId}") + public ResponseEntity deleteVisitHistory( + @PathVariable Long historyId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + visitHistoryService.deleteVisitHistory(historyId, userDetails.getId()); + return ResponseEntity.ok().build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryDto.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryDto.java deleted file mode 100644 index d3b8424..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.visithistory.dto; - -public class VisitHistoryDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java new file mode 100644 index 0000000..33b7796 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java @@ -0,0 +1,16 @@ +package com.teamEWSN.gitdeun.visithistory.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class VisitHistoryResponseDto { + private Long visitHistoryId; + private Long mindmapId; + private String mindmapField; // 마인드맵 제목 + private String repoUrl; + private LocalDateTime lastVisitedAt; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java new file mode 100644 index 0000000..206ab68 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.visithistory.mapper; + +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface VisitHistoryMapper { + + @Mapping(source = "id", target = "visitHistoryId") + @Mapping(source = "mindmap.id", target = "mindmapId") + @Mapping(source = "mindmap.field", target = "mindmapField") + @Mapping(source = "mindmap.repo.githubRepoUrl", target = "repoUrl") + VisitHistoryResponseDto toResponseDto(VisitHistory visitHistory); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java index a03ff56..ee928c6 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java @@ -1,5 +1,20 @@ package com.teamEWSN.gitdeun.visithistory.repository; -public class VisitHistoryRepository { - +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface VisitHistoryRepository extends JpaRepository { + + // 사용자의 핀 고정되지 않은 방문 기록을 최신순으로 조회 + @Query("SELECT v FROM VisitHistory v LEFT JOIN v.pinnedHistorys p " + + "WHERE v.user = :user AND p IS NULL " + + "ORDER BY v.lastVisitedAt DESC") + List findUnpinnedHistoriesByUser(@Param("user") User user); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java index b655d9f..2eae964 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java @@ -1,5 +1,77 @@ package com.teamEWSN.gitdeun.visithistory.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.user.service.UserService; +import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; +import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import com.teamEWSN.gitdeun.visithistory.mapper.VisitHistoryMapper; +import com.teamEWSN.gitdeun.visithistory.repository.PinnedHistoryRepository; +import com.teamEWSN.gitdeun.visithistory.repository.VisitHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class VisitHistoryService { - + + private final UserService userService; + private final VisitHistoryRepository visitHistoryRepository; + private final PinnedHistoryRepository pinnedHistoryRepository; + private final VisitHistoryMapper visitHistoryMapper; + + // 마인드맵 생성 시 호출되어 방문 기록을 생성 + @Transactional + public void createVisitHistory(User user, Mindmap mindmap) { + VisitHistory visitHistory = VisitHistory.builder() + .user(user) + .mindmap(mindmap) + .lastVisitedAt(LocalDateTime.now()) + .build(); + visitHistoryRepository.save(visitHistory); + } + + // 핀 고정되지 않은 방문 기록 조회 + @Transactional(readOnly = true) + public List getVisitHistories(Long userId) { + User user = userService.findById(userId); + List histories = visitHistoryRepository.findUnpinnedHistoriesByUser(user); + return histories.stream() + .map(visitHistoryMapper::toResponseDto) + .collect(Collectors.toList()); + } + + // 핀 고정된 방문 기록 조회 + @Transactional(readOnly = true) + public List getPinnedHistories(Long userId) { + User user = userService.findById(userId); + List pinnedHistories = pinnedHistoryRepository.findByUserOrderByCreatedAtDesc(user); + return pinnedHistories.stream() + .map(pinned -> visitHistoryMapper.toResponseDto(pinned.getVisitHistory())) + .collect(Collectors.toList()); + } + + // 방문 기록 삭제 + @Transactional + public void deleteVisitHistory(Long visitHistoryId, Long userId) { + VisitHistory visitHistory = visitHistoryRepository.findById(visitHistoryId) + .orElseThrow(() -> new GlobalException(ErrorCode.HISTORY_NOT_FOUND)); + + if (!visitHistory.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + visitHistoryRepository.delete(visitHistory); + } + + } \ No newline at end of file From e23a49d6b09c741fd4a6087fe51131d4c9a0969c Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Thu, 31 Jul 2025 01:08:27 +0900 Subject: [PATCH 46/60] =?UTF-8?q?style:=20=EC=95=88=EC=93=B0=EB=8A=94=20im?= =?UTF-8?q?port=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java | 1 - .../gitdeun/visithistory/service/VisitHistoryService.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java index 206ab68..d784911 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java @@ -1,6 +1,5 @@ package com.teamEWSN.gitdeun.visithistory.mapper; -import com.teamEWSN.gitdeun.user.dto.UserResponseDto; import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; import org.mapstruct.Mapper; diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java index 2eae964..30a914f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java @@ -4,7 +4,6 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.user.entity.User; -import com.teamEWSN.gitdeun.user.repository.UserRepository; import com.teamEWSN.gitdeun.user.service.UserService; import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; From f57cce1b1fabba022c02a6d8f398c759e11161da Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 1 Aug 2025 04:15:06 +0900 Subject: [PATCH 47/60] =?UTF-8?q?refactor:=20UserSetting=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/user/dto/UserSettingResponseDto.java | 1 - .../user/dto/UserSettingUpdateRequestDto.java | 2 -- .../teamEWSN/gitdeun/user/entity/UserSetting.java | 15 +-------------- .../gitdeun/user/service/UserSettingService.java | 2 +- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java index 4f54f79..663f577 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java @@ -12,6 +12,5 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserSettingResponseDto { private UserSetting.DisplayTheme theme; - private UserSetting.UserMode mode; private boolean emailNotification; } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java index d7683d3..d65117b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java @@ -12,8 +12,6 @@ public class UserSettingUpdateRequestDto { @NotNull(message = "테마를 선택해주세요.") private UserSetting.DisplayTheme theme; - @NotNull(message = "모드를 선택해주세요.") - private UserSetting.UserMode mode; @NotNull(message = "이메일 수신 여부를 선택해주세요.") private Boolean emailNotification; diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java index 773133e..29a9ac2 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java @@ -28,13 +28,6 @@ public class UserSetting { @ColumnDefault("'LIGHT'") private DisplayTheme theme = DisplayTheme.LIGHT; - // 사용자 모드 (GENERAL, DEVELOPER) - @Builder.Default - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - @ColumnDefault("'GENERAL'") - private UserMode mode = UserMode.GENERAL; - @Builder.Default @Column(name = "email_notification", nullable = false) @ColumnDefault("true") @@ -45,15 +38,10 @@ public enum DisplayTheme { LIGHT, DARK } - public enum UserMode { - GENERAL, DEVELOPER - } - @Builder - private UserSetting(User user, DisplayTheme theme, UserMode mode, boolean emailNotification) { + private UserSetting(User user, DisplayTheme theme, boolean emailNotification) { this.user = user; this.theme = theme; - this.mode = mode; this.emailNotification = emailNotification; } @@ -65,7 +53,6 @@ public static UserSetting createDefault(User user) { public void update(UserSettingUpdateRequestDto dto) { this.theme = dto.getTheme(); - this.mode = dto.getMode(); this.emailNotification = dto.getEmailNotification(); } } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java index a3a00ad..b812700 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java @@ -47,7 +47,7 @@ public UserSettingResponseDto getSettings(Long userId) { * @param requestDto 업데이트할 설정 내용 * @return 업데이트된 사용자의 설정 정보 DTO */ - @Transactional // 쓰기 작업을 위한 @Transactional + @Transactional public UserSettingResponseDto updateSettings(Long userId, UserSettingUpdateRequestDto requestDto) { UserSetting userSetting = userSettingRepository.findByUserId(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_SETTING_NOT_FOUND_BY_ID)); From a1d0559aa26926ceda9f9bb829ffd3878d5969b8 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 1 Aug 2025 04:15:38 +0900 Subject: [PATCH 48/60] =?UTF-8?q?refactor:=20Repo=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20RepoService?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/controller/MindmapController.java | 10 ++++------ .../gitdeun/mindmap/service/MindmapService.java | 8 ++++---- .../teamEWSN/gitdeun/repo/service/RepoService.java | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index b1ed9bb..dbfeaf5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -13,14 +13,14 @@ import org.springframework.web.bind.annotation.*; @Slf4j -@RestController +@RestController("/api/mindmaps") @RequiredArgsConstructor public class MindmapController { private final MindmapService mindmapService; - // 마인드맵 생성 + // 마인드맵 생성 (마인드맵에 한해서 owner 권한 얻음) @PostMapping public ResponseEntity createMindmap( @RequestBody MindmapCreateRequest request, @@ -30,7 +30,7 @@ public ResponseEntity createMindmap( return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } - // 마인드맵 상세 조회(유저 인가 확인필요) + // 마인드맵 상세 조회 (유저 인가 확인필요?) @GetMapping("/{mapId}") public ResponseEntity getMindmap( @PathVariable Long mapId @@ -49,7 +49,7 @@ public ResponseEntity refreshMindmap( return ResponseEntity.ok(responseDto); } - // 마인드맵 삭제 + // 마인드맵 삭제 (owner만) @DeleteMapping("/{mapId}") public ResponseEntity deleteMindmap( @PathVariable Long mapId, @@ -60,6 +60,4 @@ public ResponseEntity deleteMindmap( } - // TODO: 마인드맵 방문 시 / 새로고침 시 업뎃 자동 확인 + 재동기화 - } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index a88f024..9e10d54 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -13,6 +13,7 @@ import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.repo.repository.RepoRepository; +import com.teamEWSN.gitdeun.repo.service.RepoService; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; @@ -29,7 +30,9 @@ @Service @RequiredArgsConstructor public class MindmapService { + private final VisitHistoryService visitHistoryService; + private final RepoService repoService; private final MindmapMapper mindmapMapper; private final MindmapRepository mindmapRepository; private final RepoRepository repoRepository; @@ -43,10 +46,7 @@ public MindmapResponseDto createMindmap(MindmapCreateRequest req, Long userId) { AnalysisResultDto dto = fastApiClient.analyze(req.getRepoUrl(), req.getPrompt(), req.getType()); - Repo repo = repoRepository.findByGithubRepoUrl(req.getRepoUrl()) - .orElseGet(() -> Repo.builder().githubRepoUrl(req.getRepoUrl()).build()); - - repo.updateWithAnalysis(dto); + Repo repo = repoService.createOrUpdate(req.getRepoUrl(), dto); repoRepository.save(repo); String field; diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java index 6994c92..02fbd20 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -3,6 +3,7 @@ import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; import com.teamEWSN.gitdeun.repo.dto.RepoUpdateCheckResponseDto; import com.teamEWSN.gitdeun.repo.entity.Repo; @@ -57,4 +58,17 @@ public RepoUpdateCheckResponseDto checkUpdateNeeded(Long repoId) { return new RepoUpdateCheckResponseDto(isNeeded); } + // 마인드맵 생성 시 repo 생성 및 업데이트 + @Transactional + public Repo createOrUpdate(String repoUrl, AnalysisResultDto dto) { + return repoRepository.findByGithubRepoUrl(repoUrl) + .map(r -> { r.updateWithAnalysis(dto); return r; }) + .orElseGet(() -> { + Repo repo = Repo.builder() + .githubRepoUrl(repoUrl) + .build(); + repo.updateWithAnalysis(dto); + return repo; + }); + } } \ No newline at end of file From 431d4ee91f52c764fbb4c787ba8657fe75665297 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 1 Aug 2025 04:47:52 +0900 Subject: [PATCH 49/60] =?UTF-8?q?feat:=20Mindmap=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=20mindmapmember=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/mindmap/entity/MindmapMember.java | 42 +++++++++++++++++++ .../gitdeun/mindmap/entity/MindmapRole.java | 7 ++++ .../repository/MindmapMemberRepository.java | 11 +++++ .../mindmap/service/MindmapService.java | 9 ++++ 4 files changed, 69 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapMember.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapRole.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapMember.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapMember.java new file mode 100644 index 0000000..4fdf37f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapMember.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.mindmap.entity; + +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mindmap_member") +public class MindmapMember extends CreatedEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + // member + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private MindmapRole role; + + // 생성일이 멤버 수락일 + + public static MindmapMember of(Mindmap mindmap, User user, MindmapRole role) { + MindmapMember member = new MindmapMember(); + member.mindmap = mindmap; + member.user = user; + member.role = role; // OWNER‧EDITOR‧VIEWER 등 + return member; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapRole.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapRole.java new file mode 100644 index 0000000..53e5c87 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapRole.java @@ -0,0 +1,7 @@ +package com.teamEWSN.gitdeun.mindmap.entity; + +public enum MindmapRole { + OWNER, // 생성자 + EDITOR, // 수정 가능 + VIEWER; // 읽기 전용 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java new file mode 100644 index 0000000..1ba5abd --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java @@ -0,0 +1,11 @@ +package com.teamEWSN.gitdeun.mindmap.repository; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapMember; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MindmapMemberRepository extends JpaRepository { + + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 9e10d54..d912e1a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -8,8 +8,11 @@ import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.entity.MindmapMember; +import com.teamEWSN.gitdeun.mindmap.entity.MindmapRole; import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; import com.teamEWSN.gitdeun.mindmap.mapper.MindmapMapper; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapMemberRepository; import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.repo.repository.RepoRepository; @@ -35,6 +38,7 @@ public class MindmapService { private final RepoService repoService; private final MindmapMapper mindmapMapper; private final MindmapRepository mindmapRepository; + private final MindmapMemberRepository mindmapMemberRepository; private final RepoRepository repoRepository; private final UserRepository userRepository; private final FastApiClient fastApiClient; @@ -74,6 +78,11 @@ public MindmapResponseDto createMindmap(MindmapCreateRequest req, Long userId) { mindmapRepository.save(mindmap); + // 마인드맵 소유자 등록 + mindmapMemberRepository.save( + MindmapMember.of(mindmap, user, MindmapRole.OWNER) + ); + // 방문 기록 생성 visitHistoryService.createVisitHistory(user, mindmap); From b5bcdf2af8ebe8fcbb35c70564b14ea49287b7e6 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 1 Aug 2025 05:48:41 +0900 Subject: [PATCH 50/60] =?UTF-8?q?feat:=20Mindmap=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MindmapAuthService.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java new file mode 100644 index 0000000..74059c4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java @@ -0,0 +1,31 @@ +package com.teamEWSN.gitdeun.mindmapmember.service; + +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MindmapAuthService { + + private final MindmapMemberRepository memberRepo; + + /** OWNER 확인 */ + public boolean isOwner(Long mapId, Long userId) { + return memberRepo.existsByMindmapIdAndUserIdAndType(mapId, userId, MindmapRole.OWNER); + } + + /** 수정 권한(OWNER, EDITOR) */ + public boolean hasEdit(Long mapId, Long userId) { + return memberRepo.existsByMindmapIdAndUserIdAndTypeIn( + mapId, userId, List.of(MindmapRole.OWNER, MindmapRole.EDITOR)); + } + + /** 열람 권한(모든 멤버) */ + public boolean hasView(Long mapId, Long userId) { + return memberRepo.existsByMindmapIdAndUserId(mapId, userId); + } +} From 4154b77f7d700304d7000c874ec3d4333e855120 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 1 Aug 2025 05:50:11 +0900 Subject: [PATCH 51/60] =?UTF-8?q?feat:=20MindmapMember=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 멤버의 권한 변경 - 멤버 강제 추방 --- .../gitdeun/common/exception/ErrorCode.java | 3 ++ .../mindmap/controller/MindmapController.java | 4 +- ...uest.java => MindmapCreateRequestDto.java} | 2 +- .../repository/MindmapMemberRepository.java | 11 ----- .../mindmap/service/MindmapService.java | 10 ++--- .../controller/MindmapMemberController.java | 40 +++++++++++++++++ .../dto/RoleChangeRequestDto.java | 15 +++++++ .../entity/MindmapMember.java | 12 +++--- .../entity/MindmapRole.java | 2 +- .../repository/MindmapMemberRepository.java | 28 ++++++++++++ .../service/MindmapMemberService.java | 43 +++++++++++++++++++ 11 files changed, 145 insertions(+), 25 deletions(-) rename src/main/java/com/teamEWSN/gitdeun/mindmap/dto/{MindmapCreateRequest.java => MindmapCreateRequestDto.java} (90%) delete mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapmember/dto/RoleChangeRequestDto.java rename src/main/java/com/teamEWSN/gitdeun/{mindmap => mindmapmember}/entity/MindmapMember.java (80%) rename src/main/java/com/teamEWSN/gitdeun/{mindmap => mindmapmember}/entity/MindmapRole.java (69%) create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java create mode 100644 src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 3586467..62cee37 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -41,6 +41,9 @@ public enum ErrorCode { // 마인드맵 관련 MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "요청한 마인드맵을 찾을 수 없습니다."), + // 멤버 관련 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-001", "해당 멤버를 찾을 수 없습니다."), + // 방문기록 관련 HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index dbfeaf5..09f6c16 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.mindmap.controller; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequest; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; import com.teamEWSN.gitdeun.mindmap.service.MindmapService; @@ -23,7 +23,7 @@ public class MindmapController { // 마인드맵 생성 (마인드맵에 한해서 owner 권한 얻음) @PostMapping public ResponseEntity createMindmap( - @RequestBody MindmapCreateRequest request, + @RequestBody MindmapCreateRequestDto request, @AuthenticationPrincipal CustomUserDetails userDetails ) { MindmapResponseDto responseDto = mindmapService.createMindmap(request, userDetails.getId()); diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java similarity index 90% rename from src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java rename to src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java index aca2939..2b3818d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequest.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java @@ -6,7 +6,7 @@ @Getter @NoArgsConstructor -public class MindmapCreateRequest { +public class MindmapCreateRequestDto { private String repoUrl; private String prompt; private MindmapType type; diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java deleted file mode 100644 index 1ba5abd..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapMemberRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.teamEWSN.gitdeun.mindmap.repository; - -import com.teamEWSN.gitdeun.mindmap.entity.MindmapMember; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface MindmapMemberRepository extends JpaRepository { - - -} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index d912e1a..88feaf0 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -4,15 +4,15 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequest; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapMember; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapRole; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; import com.teamEWSN.gitdeun.mindmap.mapper.MindmapMapper; -import com.teamEWSN.gitdeun.mindmap.repository.MindmapMemberRepository; +import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.repo.repository.RepoRepository; @@ -44,7 +44,7 @@ public class MindmapService { private final FastApiClient fastApiClient; @Transactional - public MindmapResponseDto createMindmap(MindmapCreateRequest req, Long userId) { + public MindmapResponseDto createMindmap(MindmapCreateRequestDto req, Long userId) { User user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java new file mode 100644 index 0000000..6562aee --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.mindmapmember.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.mindmapmember.dto.RoleChangeRequestDto; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapMemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController("/api/mindmaps") +@RequiredArgsConstructor +public class MindmapMemberController { + + private final MindmapMemberService memberService; + + // 마인드맵 멤버 권한 변경 + @PatchMapping("/{mapId}/members/{memberId}/role") + public ResponseEntity updateRole( + @PathVariable Long mapId, + @PathVariable Long memberId, + @RequestBody RoleChangeRequestDto dto, + @AuthenticationPrincipal CustomUserDetails user) { + memberService.changeRole(mapId, memberId, dto.getRole(), user.getId()); + return ResponseEntity.ok().build(); + } + + // 마인드맵 멤버 추방 + @DeleteMapping("/{mapId}/members/{memberId}") + public ResponseEntity kickMember( + @PathVariable Long mapId, + @PathVariable Long memberId, + @AuthenticationPrincipal CustomUserDetails user) { + memberService.removeMember(mapId, memberId, user.getId()); + return ResponseEntity.ok().build(); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/dto/RoleChangeRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/dto/RoleChangeRequestDto.java new file mode 100644 index 0000000..c784706 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/dto/RoleChangeRequestDto.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.mindmapmember.dto; + +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RoleChangeRequestDto { + private MindmapRole role; // OWNER, EDITOR, VIEWER +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapMember.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapMember.java similarity index 80% rename from src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapMember.java rename to src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapMember.java index 4fdf37f..004fcb9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapMember.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapMember.java @@ -1,13 +1,11 @@ -package com.teamEWSN.gitdeun.mindmap.entity; +package com.teamEWSN.gitdeun.mindmapmember.entity; import com.teamEWSN.gitdeun.common.util.CreatedEntity; -import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; - @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -36,7 +34,11 @@ public static MindmapMember of(Mindmap mindmap, User user, MindmapRole role) { MindmapMember member = new MindmapMember(); member.mindmap = mindmap; member.user = user; - member.role = role; // OWNER‧EDITOR‧VIEWER 등 + member.role = role; // OWNER‧EDITOR‧VIEWER return member; } + + public void updateRole(MindmapRole newRole) { + this.role = newRole; + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapRole.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapRole.java similarity index 69% rename from src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapRole.java rename to src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapRole.java index 53e5c87..c507e38 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapRole.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapRole.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.mindmap.entity; +package com.teamEWSN.gitdeun.mindmapmember.entity; public enum MindmapRole { OWNER, // 생성자 diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java new file mode 100644 index 0000000..188fa4f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java @@ -0,0 +1,28 @@ +package com.teamEWSN.gitdeun.mindmapmember.repository; + +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Optional; + +@Repository +public interface MindmapMemberRepository extends JpaRepository { + + /* OWNER/EDITOR/VIEWER 여부 */ + boolean existsByMindmapIdAndUserId(Long mindmapId, Long userId); + + boolean existsByMindmapIdAndUserIdAndType( + Long mindmapId, Long userId, MindmapRole role); + + boolean existsByMindmapIdAndUserIdAndTypeIn( + Long mindmapId, Long userId, Collection roles); + + // 권한 변경 + Optional findByIdAndMindmapId(Long memberId, Long mindmapId); + + // OWNER가 멤버 추방 + void deleteByIdAndMindmapId(Long memberId, Long mindmapId); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java new file mode 100644 index 0000000..eab5710 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.mindmapmember.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MindmapMemberService { + + private final MindmapAuthService auth; + private final MindmapMemberRepository memberRepository; + + // 멤버 권한 변경 + @Transactional + public void changeRole(Long mapId, Long memberId, + MindmapRole newRole, Long requesterId) { + + // 호출자가 OWNER인지 확인 + if (!auth.isOwner(mapId, requesterId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 대상 멤버 조회 후 role 변경 + MindmapMember member = memberRepository.findByIdAndMindmapId(memberId, mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); + member.updateRole(newRole); + } + + // 멤버 추방 + @Transactional + public void removeMember(Long mapId, Long memberId, Long requesterId) { + if (!auth.isOwner(mapId, requesterId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + memberRepository.deleteByIdAndMindmapId(memberId, mapId); + } +} From f11e68329147a91f139745dba5078a152de60e03 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 1 Aug 2025 17:36:54 +0900 Subject: [PATCH 52/60] =?UTF-8?q?feat:=20MindmapMember=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8,=20repository=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=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 --- .../mindmapmember/controller/MindmapMemberController.java | 6 +++--- .../mindmapmember/repository/MindmapMemberRepository.java | 4 ++-- .../gitdeun/mindmapmember/service/MindmapAuthService.java | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java index 6562aee..2e86b50 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java @@ -10,14 +10,14 @@ import org.springframework.web.bind.annotation.*; @Slf4j -@RestController("/api/mindmaps") +@RestController("/api/mindmaps/{mapId}/members/{memberId}") @RequiredArgsConstructor public class MindmapMemberController { private final MindmapMemberService memberService; // 마인드맵 멤버 권한 변경 - @PatchMapping("/{mapId}/members/{memberId}/role") + @PatchMapping("/role") public ResponseEntity updateRole( @PathVariable Long mapId, @PathVariable Long memberId, @@ -28,7 +28,7 @@ public ResponseEntity updateRole( } // 마인드맵 멤버 추방 - @DeleteMapping("/{mapId}/members/{memberId}") + @DeleteMapping public ResponseEntity kickMember( @PathVariable Long mapId, @PathVariable Long memberId, diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java index 188fa4f..177c596 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java @@ -14,10 +14,10 @@ public interface MindmapMemberRepository extends JpaRepository roles); // 권한 변경 diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java index 74059c4..25ba2a0 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java @@ -15,12 +15,12 @@ public class MindmapAuthService { /** OWNER 확인 */ public boolean isOwner(Long mapId, Long userId) { - return memberRepo.existsByMindmapIdAndUserIdAndType(mapId, userId, MindmapRole.OWNER); + return memberRepo.existsByMindmapIdAndUserIdAndRole(mapId, userId, MindmapRole.OWNER); } /** 수정 권한(OWNER, EDITOR) */ public boolean hasEdit(Long mapId, Long userId) { - return memberRepo.existsByMindmapIdAndUserIdAndTypeIn( + return memberRepo.existsByMindmapIdAndUserIdAndRoleIn( mapId, userId, List.of(MindmapRole.OWNER, MindmapRole.EDITOR)); } From 3452bff961f34ea37d292edb8f457ffe41304e01 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Fri, 1 Aug 2025 19:01:45 +0900 Subject: [PATCH 53/60] =?UTF-8?q?fix:=20oauth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=20=EC=84=B8=EC=85=98=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?,=20role=20=EA=B6=8C=ED=95=9C=20=EC=9D=B8=EC=A6=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitdeun/common/config/SecurityConfig.java | 2 +- .../common/jwt/JwtAuthenticationFilter.java | 2 +- .../gitdeun/common/jwt/JwtTokenProvider.java | 18 ++++++++++-------- .../{record => dto}/GoogleTokenResponse.java | 2 +- .../oauth/service/CustomOAuth2UserService.java | 2 +- .../common/oauth/service/GoogleApiHelper.java | 2 +- .../service/SocialTokenRefreshService.java | 2 +- .../com/teamEWSN/gitdeun/user/entity/Role.java | 6 +++--- .../com/teamEWSN/gitdeun/user/entity/User.java | 2 +- 9 files changed, 20 insertions(+), 18 deletions(-) rename src/main/java/com/teamEWSN/gitdeun/common/oauth/{record => dto}/GoogleTokenResponse.java (88%) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java index 56436f8..728e34d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -44,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) // 필요한 경우 세션 요청 .headers((headerConfig) -> headerConfig .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java index ebf6aa6..3c5ef4f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java @@ -23,7 +23,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private String secretKey; private final JwtTokenProvider jwtTokenProvider; private final ObjectMapper objectMapper; - private static final String BEARER = "Bearer"; + private static final String BEARER = "Bearer "; @Override diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java index 281e51b..1f111ff 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java @@ -11,11 +11,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.UUID; @@ -101,17 +104,16 @@ public JwtToken generateToken(Authentication authentication) { public Authentication getAuthentication(String token) { Claims claims = jwtTokenParser.parseClaims(token); Long userId = Long.valueOf(claims.getSubject()); - Role role = Role.valueOf(claims.get("role", String.class)); - + String roleName = claims.get("role", String.class); + Role role = Role.valueOf(roleName); User user = userService.findById(userId); - CustomUserDetails userDetails = - new CustomUserDetails(user.getId(), user.getEmail(), - user.getNickname(), user.getProfileImage(), - role, user.getName()); + CustomUserDetails userDetails = new CustomUserDetails(user.getId(), user.getEmail(), + user.getNickname(), user.getProfileImage(), role, user.getName()); + + Collection authorities = Collections.singletonList(role); - return new UsernamePasswordAuthenticationToken( - userDetails, null, Collections.singletonList(role::name)); + return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } // // 토큰 생성 - 유저 정보 이용 diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GoogleTokenResponse.java similarity index 88% rename from src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java rename to src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GoogleTokenResponse.java index dceba76..67d7b5e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GoogleTokenResponse.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.common.oauth.record; +package com.teamEWSN.gitdeun.common.oauth.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index d9d2310..0091d49 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -125,7 +125,7 @@ private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, S .name(response.getName()) // GitHub의 경우 full name, Google의 경우 name .nickname(nickname) .profileImage(response.getProfileImageUrl()) - .role(Role.ROLE_USER) + .role(Role.USER) .build(); connectSocialAccount(newUser, provider, providerId, accessToken, refreshToken); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java index dadc42b..753a0cd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -2,7 +2,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; -import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; +import com.teamEWSN.gitdeun.common.oauth.dto.GoogleTokenResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java index 41c22d0..3ee34bc 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -4,7 +4,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; -import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; +import com.teamEWSN.gitdeun.common.oauth.dto.GoogleTokenResponse; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java index bee52db..0099634 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java @@ -3,11 +3,11 @@ import org.springframework.security.core.GrantedAuthority; public enum Role implements GrantedAuthority { - ROLE_USER, - ROLE_ADMIN, USER; + USER, + ADMIN; @Override public String getAuthority() { - return name(); + return "ROLE_" + name(); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 7cb2495..07a5ff8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -37,7 +37,7 @@ public class User extends AuditedEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List socialConnections = new ArrayList<>(); - @Column(name = "deleted_at", columnDefinition = "DATETIME(0) DEFAULT CURRENT_TIMESTAMP") + @Column(name = "deleted_at") private LocalDateTime deletedAt; From a876b03b83d311e877befd1bd5d409fed24fba45 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 2 Aug 2025 02:21:11 +0900 Subject: [PATCH 54/60] =?UTF-8?q?feat:=20securityPath=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/teamEWSN/gitdeun/common/config/SecurityPath.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java index 9581677..5636642 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -5,7 +5,7 @@ public class SecurityPath { // permitAll public static final String[] PUBLIC_ENDPOINTS = { - "/api/token/refresh", + "/api/auth/token/refresh", "/api/auth/oauth/refresh/*", "/", @@ -16,8 +16,11 @@ public class SecurityPath { "/api/auth/connect/github/state", "/api/users/me", "/api/users/me/**", - "/api/logout", - "/api/repos/**" + "/api/auth/logout", + "/api/repos", + "/api/repos/**", + "/api/mindmaps/**", + "/api/history/**" }; // hasRole("ADMIN") From 8c9a3155e8fffda2bf3b6578da8071118c75bdb7 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 2 Aug 2025 02:21:44 +0900 Subject: [PATCH 55/60] =?UTF-8?q?fix:=20connectSocialAccount()=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=84=EC=97=90=20User=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/oauth/service/CustomOAuth2UserService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index 0091d49..09581ae 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -127,9 +127,10 @@ private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, S .profileImage(response.getProfileImageUrl()) .role(Role.USER) .build(); + User savedUser = userRepository.save(newUser); - connectSocialAccount(newUser, provider, providerId, accessToken, refreshToken); - return userRepository.save(newUser); + connectSocialAccount(savedUser, provider, providerId, accessToken, refreshToken); + return savedUser; } private void connectSocialAccount(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) { From eb153c635c55e09844f1716f0d97868f44ae2e02 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 2 Aug 2025 05:41:33 +0900 Subject: [PATCH 56/60] =?UTF-8?q?fix:=20JwtTokenProvider=20getAuthenticati?= =?UTF-8?q?on=20=EC=9D=B8=EC=A6=9D=20=EC=BC=80=EC=9D=B4=EC=8A=A4=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 --- .../gitdeun/common/jwt/JwtTokenProvider.java | 14 ++++++++++---- .../oauth/service/CustomOAuth2UserService.java | 9 +++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java index 1f111ff..b052f47 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java @@ -1,5 +1,6 @@ package com.teamEWSN.gitdeun.common.jwt; +import com.teamEWSN.gitdeun.common.oauth.dto.CustomOAuth2User; import com.teamEWSN.gitdeun.user.entity.Role; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.service.UserService; @@ -12,7 +13,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; @@ -61,9 +61,15 @@ public JwtToken generateToken(Authentication authentication) { Role role; switch (principal) { - case CustomUserPrincipal p -> { - userId = p.getId(); - role = Role.valueOf(p.getRole()); + // 소셜 로그인 성공 후 CustomOAuth2User를 처리 + case CustomOAuth2User customUser -> { + userId = customUser.getUserId(); + role = customUser.getRole(); + } + // 기존 JWT로 인증된 사용자를 처리 + case CustomUserDetails userDetails -> { + userId = userDetails.getId(); + role = Role.valueOf(userDetails.getRole()); } case OidcUser oidc -> { userId = userService.upsertAndGetId( diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index 09581ae..d934856 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -22,6 +22,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -74,7 +75,8 @@ public User processUser(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { // OAuth2 공급자로부터 받은 사용자 정보를 기반으로 OAuth2ResponseDto를 생성(인스턴스 메서드) private OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { String registrationId = userRequest.getClientRegistration().getRegistrationId(); - Map attr = oAuth2User.getAttributes(); + + Map attr = new HashMap<>(oAuth2User.getAttributes()); if (registrationId.equalsIgnoreCase("google")) { return new GoogleResponseDto(attr); @@ -90,10 +92,9 @@ private OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2User .findFirst().map(GitHubEmailDto::getEmail).orElse(null)); } return new GitHubResponseDto(attr); - } else { - // 지원하지 않는 소셜 로그인 제공자 - throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER); } + // 지원하지 않는 소셜 로그인 제공자 + throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER); } /** From df107fe2e262af91500b5e6ce23efe77da628bdf Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 2 Aug 2025 06:09:57 +0900 Subject: [PATCH 57/60] =?UTF-8?q?fix:=20UserSettingController=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../teamEWSN/gitdeun/user/controller/UserSettingController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java index e865b54..fe2f5ae 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java @@ -13,7 +13,7 @@ @Slf4j @RestController -@RequestMapping("/api/settings") +@RequestMapping("/api/users/me/settings") @RequiredArgsConstructor public class UserSettingController { private final UserSettingService userSettingService; From d0673c7b711a54d478269584b13f45cb5b8171a7 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sat, 2 Aug 2025 23:24:41 +0900 Subject: [PATCH 58/60] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=86=8C=EC=85=9C=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84(?= =?UTF-8?q?=ED=99=95=EC=9D=B8=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/dto/SocialConnectionResponseDto.java | 13 +++++++++++++ .../gitdeun/user/controller/AuthController.java | 11 +++++++++++ .../gitdeun/user/service/AuthService.java | 17 +++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/SocialConnectionResponseDto.java diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/SocialConnectionResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/SocialConnectionResponseDto.java new file mode 100644 index 0000000..b2c7ef2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/SocialConnectionResponseDto.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class SocialConnectionResponseDto { + private List connectedProviders; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index 4ad229b..775ccb4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -3,6 +3,7 @@ import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.oauth.dto.SocialConnectionResponseDto; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; import com.teamEWSN.gitdeun.common.oauth.service.SocialTokenRefreshService; @@ -78,4 +79,14 @@ public ResponseEntity refreshSocial( socialTokenRefreshService.refreshSocialToken(user.getId(), provider); return ResponseEntity.noContent().build(); } + + // socialconnection 확인용 + @GetMapping("/social") + public ResponseEntity getSocialConnections( + @AuthenticationPrincipal CustomUserDetails user + ) { + SocialConnectionResponseDto response = authService.getConnectedProviders(user.getId()); + return ResponseEntity.ok(response); + } + } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index 24fd669..34ef673 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -6,6 +6,7 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.*; +import com.teamEWSN.gitdeun.common.oauth.dto.SocialConnectionResponseDto; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; @@ -20,7 +21,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; @Slf4j @@ -106,4 +109,18 @@ public void connectGithubAccount(OAuth2User githubUser, Long userId) { socialConnectionRepository.save(connection); } + // 사용자의 모든 소셜 연동 정보를 조회 + @Transactional(readOnly = true) + public SocialConnectionResponseDto getConnectedProviders(Long userId) { + // userId로 사용자를 조회 + User user = userService.findById(userId); + + // 사용자의 SocialConnection 리스트에서 Provider 정보만 추출하여 리스트 생성 + List providers = user.getSocialConnections().stream() + .map(SocialConnection::getProvider) + .collect(Collectors.toList()); + + // DTO에 담아 반환 변환 + return new SocialConnectionResponseDto(providers); + } } From dc5233a3089647015829c27262cc014217c64f5b Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sun, 3 Aug 2025 15:41:23 +0900 Subject: [PATCH 59/60] =?UTF-8?q?setting:=20github.redirect-uri=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 --- src/main/resources/application-dev.yml | 2 +- src/main/resources/application-prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f8f3507..aa9f869 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -13,7 +13,7 @@ spring: github: client-id: ${GITHUB_DEV_CLIENT_ID} client-secret: ${GITHUB_DEV_CLIENT_SECRET} - redirect-uri: http://localhost:8080/api/auth/github/callback + redirect-uri: http://localhost:8080/login/oauth2/code/github # provider: # google: # authorization-uri: https://accounts.google.com/o/oauth2/v2/auth diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 07c7b4e..cf65f9a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -17,7 +17,7 @@ spring: github: client-id: ${GITHUB_PROD_CLIENT_ID} client-secret: ${GITHUB_PROD_CLIENT_SECRET} - redirect-uri: https://api.gitdeun.site/api/auth/github/callback + redirect-uri: https://api.gitdeun.site/login/oauth2/code/github jwt: From 353e32cbbe27462d5ee90c346e713afa411188d0 Mon Sep 17 00:00:00 2001 From: kobumseouk Date: Sun, 3 Aug 2025 18:04:50 +0900 Subject: [PATCH 60/60] =?UTF-8?q?setting:=20front=203000=20->=205173?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java | 2 +- src/main/resources/application-dev.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java index 728e34d..ba5ffc3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -102,7 +102,7 @@ public CorsConfigurationSource corsConfigurationSource() { private static CorsConfiguration getCorsConfiguration() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 + configuration.addAllowedOrigin("http://localhost:5173"); // 개발 환경 configuration.addAllowedOrigin("https://gitdeun.netlify.app"); configuration.addAllowedOrigin("https://gitdeun.site"); // 혜택온 도메인 configuration.addAllowedOrigin("https://www.gitdeun.site"); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index aa9f869..37d7e1b 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -27,7 +27,7 @@ jwt: refresh-expired: 86400 # 1일 app: - front-url: http://localhost:3000 + front-url: http://localhost:5173 cookie: secure: false same-site: Lax