diff --git a/.github/workflows/main-ci-workflow.yml b/.github/workflows/main-ci-workflow.yml index b0bb7cee..dc49eb53 100644 --- a/.github/workflows/main-ci-workflow.yml +++ b/.github/workflows/main-ci-workflow.yml @@ -52,6 +52,8 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/main-deploy-workflow.yml b/.github/workflows/main-deploy-workflow.yml index c836bbb3..7c9a9be7 100644 --- a/.github/workflows/main-deploy-workflow.yml +++ b/.github/workflows/main-deploy-workflow.yml @@ -59,6 +59,8 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} steps: - uses: actions/checkout@v4 @@ -178,6 +180,8 @@ jobs: -e GOOGLE_AUTHORIZATION_URI=${{secrets.GOOGLE_AUTHORIZATION_URI}} \ -e GOOGLE_TOKEN_URI=${{secrets.GOOGLE_TOKEN_URI}} \ -e GOOGLE_USER_INFO_URI=${{secrets.GOOGLE_USER_INFO_URI}} \ + -e NAVER_MAIL_USERNAME=${{secrets.NAVER_MAIL_USERNAME}} \ + -e NAVER_MAIL_PASSWORD=${{secrets.NAVER_MAIL_PASSWORD}} \ ${{secrets.DOCKER_USERNAME}}/sequence:latest # monitoring 네트워크 연결 추가 diff --git a/.github/workflows/test-server-prTest.yml b/.github/workflows/test-server-prTest.yml index 8e3fdecf..28c10bb2 100644 --- a/.github/workflows/test-server-prTest.yml +++ b/.github/workflows/test-server-prTest.yml @@ -52,6 +52,9 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} + steps: - uses: actions/checkout@v4 - name: Set up JDK 17 diff --git a/.github/workflows/test-server-release.yml b/.github/workflows/test-server-release.yml index 7f4664de..1b18a752 100644 --- a/.github/workflows/test-server-release.yml +++ b/.github/workflows/test-server-release.yml @@ -59,6 +59,8 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} steps: - uses: actions/checkout@v4 @@ -178,6 +180,8 @@ jobs: -e GOOGLE_AUTHORIZATION_URI=${{secrets.GOOGLE_AUTHORIZATION_URI}} \ -e GOOGLE_TOKEN_URI=${{secrets.GOOGLE_TOKEN_URI}} \ -e GOOGLE_USER_INFO_URI=${{secrets.GOOGLE_USER_INFO_URI}} \ + -e NAVER_MAIL_USERNAME=${{secrets.NAVER_MAIL_USERNAME}} \ + -e NAVER_MAIL_PASSWORD=${{secrets.NAVER_MAIL_PASSWORD}} \ ${{secrets.DOCKER_USERNAME}}/test-sequence:latest # monitoring 네트워크 연결 추가 docker network connect monitoring test-sequence-spring-container diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java index 00a0a300..6e1cd749 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java @@ -23,7 +23,8 @@ public class ArchiveCommentOutputDTO { @Builder public static class CommentDTO { private Long id; - private String writer; + private String commentWriter; + private String commentWriterProfileImg; private String content; private boolean isDeleted; private LocalDateTime createdDateTime; diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java index 9e16f603..4169a64a 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java @@ -18,6 +18,7 @@ public static class ArchiveSimpleDTO { private Long id; private String title; private String writerNickname; + private String writerProfileImg; // 작성자 프로필 이미지 추가 private String thumbnail; private int commentCount; private int view; // 조회수 추가 diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java index c8bfa36d..b15dca88 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java @@ -16,7 +16,9 @@ @AllArgsConstructor public class ArchiveOutputDTO { private Long id; + private String writerUsername; private String writerNickname; + private String writerProfileImg; private String title; private String description; private LocalDate startDate; diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java b/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java index 050935e8..f14bbe41 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java @@ -43,7 +43,7 @@ public class ArchiveMember extends BaseTimeEntity { @JoinColumn(name = "archive_id") private Archive archive; - @Column(name = "profile_img") + @Column(name = "profile_img", columnDefinition = "TEXT") private String profileImg; // 멤버의 프로필 이미지 URL @Builder diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java b/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java index dee37c01..c1d26631 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java @@ -20,30 +20,50 @@ public interface ArchiveRepository extends JpaRepository { // 기본 조회 - 삭제되지 않은 아카이브만 Optional findByIdAndIsDeletedFalse(Long id); + // 작성자 정보를 함께 조회하는 최적화된 메서드 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.id = :id AND a.isDeleted = false") + Optional findByIdAndIsDeletedFalseWithWriter(@Param("id") Long id); + // 전체 목록 조회 - 삭제되지 않은 것만 Page findByIsDeletedFalse(Pageable pageable); // 상태별 조회 - 삭제되지 않은 것만 Page findByStatusAndIsDeletedFalse(Status status, Pageable pageable); + // 상태별 조회 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.status = :status AND a.isDeleted = false") + Page findByStatusAndIsDeletedFalseWithWriter(@Param("status") Status status, Pageable pageable); + // 카테고리별 조회 - 삭제되지 않은 것만 Page findByCategoryAndIsDeletedFalse(Category category, Pageable pageable); // 카테고리와 상태로 조회 - 삭제되지 않은 것만 Page findByCategoryAndStatusAndIsDeletedFalse(Category category, Status status, Pageable pageable); + // 카테고리와 상태로 조회 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.category = :category AND a.status = :status AND a.isDeleted = false") + Page findByCategoryAndStatusAndIsDeletedFalseWithWriter(@Param("category") Category category, @Param("status") Status status, Pageable pageable); + // 제목으로 검색 - 삭제되지 않은 것만 Page findByTitleContainingIgnoreCaseAndIsDeletedFalse(String title, Pageable pageable); // 제목으로 검색하고 상태로 필터링 - 삭제되지 않은 것만 Page findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse(String title, Status status, Pageable pageable); + // 제목으로 검색하고 상태로 필터링 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.title LIKE %:title% AND a.status = :status AND a.isDeleted = false") + Page findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter(@Param("title") String title, @Param("status") Status status, Pageable pageable); + // 카테고리와 제목으로 검색 - 삭제되지 않은 것만 Page findByCategoryAndTitleContainingIgnoreCaseAndIsDeletedFalse(Category category, String title, Pageable pageable); // 카테고리와 제목으로 검색하고 상태로 필터링 - 삭제되지 않은 것만 Page findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse(Category category, String title, Status status, Pageable pageable); + // 카테고리와 제목으로 검색하고 상태로 필터링 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.category = :category AND a.title LIKE %:title% AND a.status = :status AND a.isDeleted = false") + Page findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter(@Param("category") Category category, @Param("title") String title, @Param("status") Status status, Pageable pageable); + // 멤버 ID로 아카이브 검색 - 삭제되지 않은 것만 @Query("SELECT a FROM Archive a JOIN a.archiveMembers am WHERE am.member.id = :memberId AND a.isDeleted = false") Page findByMemberId(@Param("memberId") Long memberId, Pageable pageable); diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java index 65f8e708..c66fe680 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java @@ -191,7 +191,7 @@ public ArchiveListDTO getAllArchives(int page, SortType sortType, String usernam } Pageable pageable = createPageableWithSort(page, sortType); - Page archivePage = archiveRepository.findByStatusAndIsDeletedFalse(Status.평가완료, pageable); + Page archivePage = archiveRepository.findByStatusAndIsDeletedFalseWithWriter(Status.평가완료, pageable); List archives = archivePage.getContent().stream() .map(archive -> { @@ -203,6 +203,7 @@ public ArchiveListDTO getAllArchives(int page, SortType sortType, String usernam .id(archive.getId()) .title(archive.getTitle()) .writerNickname(archive.getWriter().getNickname()) + .writerProfileImg(archive.getWriter().getProfileImg()) .thumbnail(archive.getThumbnail()) .commentCount(archive.getComments().size()) .view(archive.getView()) @@ -236,15 +237,15 @@ public ArchiveListDTO searchArchives( Page archivePage; if (category != null && keyword != null && !keyword.trim().isEmpty()) { - archivePage = archiveRepository.findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse( + archivePage = archiveRepository.findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter( category, keyword.trim(), Status.평가완료, pageable); } else if (category != null) { - archivePage = archiveRepository.findByCategoryAndStatusAndIsDeletedFalse(category, Status.평가완료, pageable); + archivePage = archiveRepository.findByCategoryAndStatusAndIsDeletedFalseWithWriter(category, Status.평가완료, pageable); } else if (keyword != null && !keyword.trim().isEmpty()) { - archivePage = archiveRepository.findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse( + archivePage = archiveRepository.findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter( keyword.trim(), Status.평가완료, pageable); } else { - archivePage = archiveRepository.findByStatusAndIsDeletedFalse(Status.평가완료, pageable); + archivePage = archiveRepository.findByStatusAndIsDeletedFalseWithWriter(Status.평가완료, pageable); } List archives = archivePage.getContent().stream() @@ -257,6 +258,7 @@ public ArchiveListDTO searchArchives( .id(archive.getId()) .title(archive.getTitle()) .writerNickname(archive.getWriter().getNickname()) + .writerProfileImg(archive.getWriter().getProfileImg()) .thumbnail(archive.getThumbnail()) .commentCount(archive.getComments().size()) .view(archive.getView()) @@ -287,9 +289,17 @@ private Pageable createPageableWithSort(int page, SortType sortType) { return PageRequest.of(page, 18, sort); } + // 댓글 작성자의 프로필 이미지를 가져오는 헬퍼 메서드 + private String getCommentWriterProfileImg(String writerNickname) { + return memberRepository.findByNickname(writerNickname) + .map(MemberEntity::getProfileImg) + .orElse("default.png"); // 기본 이미지 + } + // Archive 엔티티를 DTO로 변환 private ArchiveOutputDTO convertToDTO(Archive archive, String username, int viewCount) { List memberDTOs = archive.getArchiveMembers().stream() + .filter(archiveMember -> !archiveMember.getMember().getId().equals(archive.getWriter().getId())) .map(archiveMember -> ArchiveOutputDTO.ArchiveMemberDTO.builder() .username(archiveMember.getMember().getUsername()) .nickname(archiveMember.getMember().getNickname()) @@ -319,7 +329,8 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view for (ArchiveComment parentComment : parentComments) { ArchiveCommentOutputDTO.CommentDTO parentDTO = ArchiveCommentOutputDTO.CommentDTO.builder() .id(parentComment.getId()) - .writer(parentComment.getWriter()) + .commentWriter(parentComment.getWriter()) + .commentWriterProfileImg(getCommentWriterProfileImg(parentComment.getWriter())) .content(parentComment.isDeleted() ? "삭제된 댓글입니다." : parentComment.getContent()) .isDeleted(parentComment.isDeleted()) .createdDateTime(parentComment.getCreatedDateTime()) @@ -333,7 +344,8 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view for (ArchiveComment childComment : childComments) { ArchiveCommentOutputDTO.CommentDTO childDTO = ArchiveCommentOutputDTO.CommentDTO.builder() .id(childComment.getId()) - .writer(childComment.getWriter()) + .commentWriter(childComment.getWriter()) + .commentWriterProfileImg(getCommentWriterProfileImg(childComment.getWriter())) .content(childComment.isDeleted() ? "삭제된 댓글입니다." : childComment.getContent()) .isDeleted(childComment.isDeleted()) .createdDateTime(childComment.getCreatedDateTime()) @@ -347,7 +359,9 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view return ArchiveOutputDTO.builder() .id(archive.getId()) + .writerUsername(archive.getWriter().getUsername()) .writerNickname(archive.getWriter().getNickname()) + .writerProfileImg(archive.getWriter().getProfileImg()) .title(archive.getTitle()) .description(archive.getDescription()) .startDate(archive.getStartDate()) diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java index 6b8f698a..ec41f906 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java @@ -1,9 +1,12 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; import sequence.sequence_member.global.response.Code; +@Slf4j public class AuthException extends BaseException { public AuthException(String message) { super(Code.ACCESS_DENIED,message); + log.error("접근 불가"); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java index a48c8eb4..aee2f13e 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java @@ -1,7 +1,11 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; + +@Slf4j public class BAD_REQUEST_EXCEPTION extends RuntimeException { public BAD_REQUEST_EXCEPTION(String message) { super(message); + log.error("Bad Request error"); } } \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java index f23090e0..46b96927 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java @@ -1,10 +1,12 @@ package sequence.sequence_member.global.exception; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import sequence.sequence_member.global.response.Code; import static sequence.sequence_member.global.response.Code.INTERNAL_SERVER_ERROR; +@Slf4j @Getter public class BaseException extends RuntimeException { @@ -14,48 +16,56 @@ public class BaseException extends RuntimeException { public BaseException() { super(INTERNAL_SERVER_ERROR.getMessage()); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 에러 메시지를 받는 생성자 public BaseException(String message) { super(message); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 에러 메시지와 원인을 받는 생성자 public BaseException(String message, Throwable cause) { super(message, cause); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 원인만을 받는 생성자 public BaseException(Throwable cause) { super(cause); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 에러 코드를 지정하는 생성자 public BaseException(Code errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; + log.error("BaseException 발생"); } // 에러 코드와 메시지를 받는 생성자 public BaseException(Code errorCode, String message) { super(message); this.errorCode = errorCode; + log.error("BaseException 발생"); } // 에러 코드, 메시지, 원인을 받는 생성자 public BaseException(Code errorCode, String message, Throwable cause) { super(message, cause); this.errorCode = errorCode; + log.error("BaseException 발생"); } // 에러 코드와 원인을 받는 생성자 public BaseException(Code errorCode, Throwable cause) { super(cause); this.errorCode = errorCode; + log.error("BaseException 발생"); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java index ebfcac31..ebab0d5a 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java @@ -1,9 +1,12 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; import sequence.sequence_member.global.response.Code; +@Slf4j public class CanNotFindResourceException extends BaseException { public CanNotFindResourceException(String message) { super(Code.CAN_NOT_FIND_RESOURCE,message); + log.error("해당 리소스 발견 불가"); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java index 4b7a7160..56d52e5c 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java @@ -1,7 +1,11 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; + +@Slf4j public class UserNotFindException extends RuntimeException { public UserNotFindException(String message) { super(message); + log.error("해당 유저를 찾을 수 없습니다."); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java index 65be19ee..1e6c0a18 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java @@ -2,27 +2,24 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; import java.io.IOException; +@Slf4j @Component public class OAuth2FailureHandler implements AuthenticationFailureHandler { - private static final Logger logger = LoggerFactory.getLogger(OAuth2FailureHandler.class); - @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { - logger.error("소셜 로그인 실패: {}", exception.getMessage()); - exception.printStackTrace(); // 에러 로그 확인 + log.error("소셜 로그인 실패: {}", exception.getMessage()); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"error\": \"소셜 로그인에 실패했습니다: " + exception.getMessage() + "\"}"); diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java index f9ba072d..5a6094c6 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java @@ -5,33 +5,27 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.web.util.UriComponentsBuilder; -import sequence.sequence_member.global.enums.enums.AuthProvider; +import sequence.sequence_member.member.dto.MemberPrincipal; import sequence.sequence_member.member.entity.MemberEntity; import sequence.sequence_member.member.jwt.JWTUtil; -import sequence.sequence_member.member.repository.MemberRepository; import sequence.sequence_member.member.service.TokenReissueService; import java.io.IOException; -import java.util.Optional; @Slf4j @Component public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { - private final MemberRepository memberRepository; private final TokenReissueService tokenReissueService; private final JWTUtil jwtUtil; - private final long ACCESS_TOKEN_EXPIRED_TIME = 600000L*60*1; // 1시간 - private final long REFRESH_TOKEN_EXPIRED_TIME = 600000L*60*24*7; // 7일 + private final long ACCESS_TOKEN_EXPIRED_TIME = 600000L * 60; // 1시간 + private final long REFRESH_TOKEN_EXPIRED_TIME = 600000L * 60 * 24 * 7; // 7일 public OAuth2SuccessHandler( - MemberRepository memberRepository, TokenReissueService tokenReissueService, JWTUtil jwtUtil) { - this.memberRepository = memberRepository; this.tokenReissueService = tokenReissueService; this.jwtUtil = jwtUtil; } @@ -41,27 +35,20 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - DefaultOidcUser oidcUser = (DefaultOidcUser) authentication.getPrincipal(); + MemberPrincipal memberPrincipal = (MemberPrincipal) authentication.getPrincipal(); - log.info("OAuth2 oidcUser={}", oidcUser); + log.info("OAuth2 인증 성공, Principal: {}", memberPrincipal); - String email = oidcUser.getEmail(); - String name = oidcUser.getFullName(); - String providerId = oidcUser.getName(); // 고유 사용자 ID + MemberEntity member = memberPrincipal.getMemberEntity(); - Optional optionalUser = memberRepository.findByEmail(email); + String email = member.getEmail(); + if (email == null || email.isEmpty()) { + log.error("인증된 Principal에서 이메일(email)이 null 또는 비어있습니다. 로그인 처리 실패."); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Email not found in OAuth2 principal."); + return; + } - MemberEntity member = optionalUser.orElseGet(() -> { - MemberEntity newUser = MemberEntity.createSocialMember( - email, - name, - AuthProvider.GOOGLE, - providerId - ); - return memberRepository.save(newUser); - }); - - log.info("OAuth2 로그인 성공: email={}, provider={}", member.getEmail(), member.getProvider()); + log.info("OAuth2 로그인 성공: email={}", email); String username = member.getUsername(); @@ -76,7 +63,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, // 쿠키에 refreshToken 설정 Cookie refreshTokenCookie = new Cookie("refresh", refresh); refreshTokenCookie.setHttpOnly(true); - refreshTokenCookie.setSecure(false); // 배포 시 true (https) + refreshTokenCookie.setSecure(false); // 로컬 HTTP 환경에서는 false, 배포 HTTPS 시 true로 변경해야 합니다. refreshTokenCookie.setPath("/"); refreshTokenCookie.setMaxAge((int) (REFRESH_TOKEN_EXPIRED_TIME / 1000)); response.addCookie(refreshTokenCookie); diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java b/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java index eafc8f82..4a6c4093 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java @@ -11,9 +11,16 @@ 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.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; +import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import sequence.sequence_member.member.authority.OAuth2FailureHandler; @@ -22,13 +29,15 @@ import sequence.sequence_member.member.jwt.JWTFilter; import sequence.sequence_member.member.jwt.JWTUtil; import sequence.sequence_member.member.jwt.LoginFilter; +import sequence.sequence_member.member.repository.CustomAuthorizationRequestRepository; import sequence.sequence_member.member.repository.MemberRepository; import sequence.sequence_member.member.repository.RefreshRepository; -import sequence.sequence_member.member.service.CustomOAuth2UserService; +import sequence.sequence_member.member.service.CustomOidcUserService; import sequence.sequence_member.member.service.TokenReissueService; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; @Configuration @EnableWebSecurity @@ -43,7 +52,7 @@ public class SecurityConfig { private final MemberRepository memberRepository; private final OAuth2FailureHandler oAuth2FailureHandler; private final OAuth2SuccessHandler oAuth2SuccessHandler; - private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOidcUserService customOidcUserService; @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { @@ -55,6 +64,11 @@ public BCryptPasswordEncoder bCryptPasswordEncoder(){ return new BCryptPasswordEncoder(); } + @Bean + public AuthorizationRequestRepository authorizationRequestRepository() { + return new CustomAuthorizationRequestRepository(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ // Custom LoginFilter 등록 @@ -100,24 +114,54 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { //경로별 인가 작업 http .authorizeHttpRequests((auth)->auth - .requestMatchers("/api/login", "/api/users/join", "/api/token", "/api/users/check_username", "/api/users/check_email", "/api/users/check_nickname", "/api/skills/**", "/api/users/test", "/api/auth/**").permitAll() + // 인증이 필요 없는 경로들을 먼저 정의합니다. + .requestMatchers( + "/api/login", + "/api/users/join", + "/api/token", + "/api/users/check_username", + "/api/users/check_email", + "/api/users/check_nickname", + "/api/skills/**", + "/api/users/test", + "/api/auth/**", + "/error", + "/actuator/**", + "/oauth2/**", + "/login/oauth2/code/**", + "/favicon.ico", + "/.well-known/**", + "/" + ).permitAll() .requestMatchers(HttpMethod.GET,"/api/projects/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/archive/projects/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/archive/{archiveId}").permitAll() - .requestMatchers("/api/archive/**").authenticated() - .requestMatchers("/error").permitAll() - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/oauth2/**").permitAll() - .anyRequest().authenticated()); + .requestMatchers("/api/archive/**").authenticated() // 인증 필요 + .anyRequest().authenticated()); // 나머지 모든 요청은 인증 필요 http .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(authEndpoint -> authEndpoint + .authorizationRequestRepository(authorizationRequestRepository()) + ) .userInfoEndpoint(userInfo -> userInfo - .userService(customOAuth2UserService) + .oidcUserService(customOidcUserService) ) .successHandler(oAuth2SuccessHandler) .failureHandler(oAuth2FailureHandler) ); + LinkedHashMap entryPoints = new LinkedHashMap<>(); + entryPoints.put(new AntPathRequestMatcher("/oauth2/authorization/**"), new Http403ForbiddenEntryPoint()); + entryPoints.put(new AntPathRequestMatcher("/favicon.ico"), new Http403ForbiddenEntryPoint()); + entryPoints.put(new AntPathRequestMatcher("/.well-known/**"), new Http403ForbiddenEntryPoint()); + + http + .exceptionHandling(exceptionHandling -> { + DelegatingAuthenticationEntryPoint delegatingEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints); + delegatingEntryPoint.setDefaultEntryPoint(new Http403ForbiddenEntryPoint()); // 기본 엔트리포인트 명시적으로 설정 + exceptionHandling.authenticationEntryPoint(delegatingEntryPoint); + }); + http .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/controller/AuthenticationController.java b/sequence_member/src/main/java/sequence/sequence_member/member/controller/AuthenticationController.java new file mode 100644 index 00000000..81d68343 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/controller/AuthenticationController.java @@ -0,0 +1,39 @@ +package sequence.sequence_member.member.controller; + +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +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.util.UriComponentsBuilder; +import sequence.sequence_member.global.response.ApiResponseData; + +import java.util.UUID; + +@Slf4j +@RestController +@RequestMapping("/api/social") +public class AuthenticationController { + @GetMapping("/bind/google") + public ResponseEntity> bindGoogle( + HttpSession session + ) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + log.info("계정 연동 요청: username = {}, provider = google", username); + + String bindingDataSessionKey = "oauth2_binding_username"; + session.setAttribute(bindingDataSessionKey, username); + log.info("세션에 바인딩할 사용자 이름 저장 완료: {}", username); + + String bindingStateToken = "bind:" + UUID.randomUUID(); + String redirectUri = UriComponentsBuilder + .fromUriString("http://localhost:8080" + "/oauth2/authorization/google") + .queryParam("binding_state_token", bindingStateToken) + .build() + .toUriString(); + + return ResponseEntity.ok().body(ApiResponseData.success(redirectUri)); + } +} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java b/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java index b3397696..a7f613f3 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java @@ -4,7 +4,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java b/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java index 1b3ce398..98aac0b8 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java @@ -3,21 +3,27 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import sequence.sequence_member.member.entity.MemberEntity; import java.util.Collection; import java.util.Collections; import java.util.Map; -public class MemberPrincipal implements OAuth2User, UserDetails { +public class MemberPrincipal implements OidcUser, UserDetails { private final MemberEntity member; private final Map attributes; + private final OidcIdToken idToken; + private final OidcUserInfo userInfo; - public MemberPrincipal(MemberEntity member, Map attributes) { + public MemberPrincipal(MemberEntity member, Map attributes, OidcIdToken idToken, OidcUserInfo userInfo) { this.member = member; this.attributes = attributes; + this.idToken = idToken; + this.userInfo = userInfo; } public MemberEntity getMember() { @@ -69,7 +75,26 @@ public String getName() { return member.getName(); } - public static MemberPrincipal create(MemberEntity member, Map attributes) { - return new MemberPrincipal(member, attributes); + public static MemberPrincipal create(MemberEntity member, Map attributes, OidcIdToken idToken, OidcUserInfo userInfo) { + return new MemberPrincipal(member, attributes, idToken, userInfo); + } + + @Override + public Map getClaims() { + return Map.of(); + } + + @Override + public OidcUserInfo getUserInfo() { + return null; + } + + @Override + public OidcIdToken getIdToken() { + return null; + } + + public MemberEntity getMemberEntity() { + return this.member; } } \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java b/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java index 6990e3ea..1182eca4 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java @@ -2,7 +2,7 @@ import jakarta.persistence.*; import lombok.Data; - +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import sequence.sequence_member.global.enums.enums.Degree; import sequence.sequence_member.global.enums.enums.ProjectRole; @@ -18,6 +18,7 @@ @Entity @Data +@EqualsAndHashCode(callSuper = false) @Table(name="education") @NoArgsConstructor public class EducationEntity extends BaseTimeEntity { diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberAuthProvider.java b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberAuthProvider.java new file mode 100644 index 00000000..cc46a1e8 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberAuthProvider.java @@ -0,0 +1,39 @@ +package sequence.sequence_member.member.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sequence.sequence_member.global.enums.enums.AuthProvider; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member_auth_provider") +public class MemberAuthProvider { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private MemberEntity member; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 50) + private AuthProvider provider; + + @Column(name = "provider_id", nullable = true, unique = true, length = 255) + private String providerId; + + public MemberAuthProvider(MemberEntity member, AuthProvider provider, String providerId) { + this.member = member; + this.provider = provider; + this.providerId = providerId; + } + + protected void setMember(MemberEntity member) { + this.member = member; + } +} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java index 71eb9218..48d5fb8a 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java @@ -3,7 +3,6 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; -import sequence.sequence_member.global.enums.enums.AuthProvider; import sequence.sequence_member.global.utils.BaseTimeEntity; import sequence.sequence_member.member.dto.MemberDTO; @@ -99,18 +98,19 @@ public static MemberEntity toMemberEntity(MemberDTO memberDTO){ return memberEntity; } - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private AuthProvider provider = AuthProvider.LOCAL; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private List authProviders = new ArrayList<>(); - @Column(length = 100) - private String providerId; + public void addAuthProviderIfNotExists(MemberAuthProvider authProvider) { + if (!this.authProviders.contains(authProvider)) { + this.authProviders.add(authProvider); + authProvider.setMember(this); + } + } public static MemberEntity createSocialMember( String email, - String name, - AuthProvider provider, - String providerId + String name ) { MemberEntity member = new MemberEntity(); member.setUsername(email); @@ -124,8 +124,6 @@ public static MemberEntity createSocialMember( member.setNickname("user_" + UUID.randomUUID().toString().substring(0, 8)); member.setSchoolName("소셜 로그인 사용자"); member.setIntroduction("소셜 로그인 사용자입니다."); - member.setProvider(provider); - member.setProviderId(providerId); return member; } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java b/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java new file mode 100644 index 00000000..a4144fad --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java @@ -0,0 +1,134 @@ +package sequence.sequence_member.member.repository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +public class CustomAuthorizationRequestRepository implements AuthorizationRequestRepository { + + public static final String SESSION_ATTR_NAME = "SPRING_SECURITY_OAUTH2_AUTHORIZATION_REQUEST"; + public static final String SPRING_SECURITY_OAUTH2_BINDING_DATA = "SPRING_SECURITY_OAUTH2_BINDING_DATA"; + + private final HttpSessionOAuth2AuthorizationRequestRepository delegate = new HttpSessionOAuth2AuthorizationRequestRepository(); + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + HttpSession session = request.getSession(false); + log.debug("DEBUG: loadAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); + if (session != null) { + StringBuilder attributes = new StringBuilder("["); + Enumeration attributeNames = session.getAttributeNames(); + while (attributeNames.hasMoreElements()) { + attributes.append(attributeNames.nextElement()); + if (attributeNames.hasMoreElements()) { + attributes.append(", "); + } + } + attributes.append("]"); + log.debug("DEBUG: loadAuthorizationRequest - Session attributes: {}", attributes); + } + return delegate.loadAuthorizationRequest(request); + } + + @Override + public void saveAuthorizationRequest( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response + ) { + log.info("🔐 saveAuthorizationRequest 호출됨 - URI: {} (Method: {})", request.getRequestURI(), request.getMethod()); + + HttpSession session = request.getSession(); + log.debug("Session ID (save): {}", session.getId()); + log.debug("Session attributes BEFORE delegate save: {}", getSessionAttributesString(session)); + + String bindingUsernameStr = (String) session.getAttribute("oauth2_binding_username"); + session.removeAttribute("oauth2_binding_username"); + + if (authorizationRequest == null) { + delegate.saveAuthorizationRequest(null, request, response); + log.debug("Session attributes after null request cleanup: {}", getSessionAttributesString(session)); + return; + } + + // Spring Security가 생성한 기본 state 값 (CSRF 방어용) + String originalSpringSecurityState = authorizationRequest.getState(); + + // 클라이언트(프런트엔드)로부터 전달된 커스텀 파라미터 (계정 연동 요청 시 전달됨) + String bindingStateToken = request.getParameter("binding_state_token"); + + log.debug("DEBUG: Original SS State = {}", originalSpringSecurityState); + log.info("DEBUG: binding_user_name Parameter = {}", bindingUsernameStr); + log.info("DEBUG: binding_state_token Parameter = {}", bindingStateToken); + + // 1. Spring Security의 OAuth2AuthorizationRequest 객체는 delegate를 통해 세션에 저장 + delegate.saveAuthorizationRequest(authorizationRequest, request, response); + log.debug("DEBUG: After setting {}: {}", SESSION_ATTR_NAME, getSessionAttributesString(session)); + + // 2. 만약 커스텀 연동 파라미터(username와 bindingStateToken)가 존재하고 유효하면, + // 이 정보를 Map 형태로 구성하여 세션에 별도로 저장 + if (bindingUsernameStr != null && bindingStateToken != null && bindingStateToken.startsWith("bind:")) { + // Map의 키를 originalSpringSecurityState와 조합하여 세션에 저장 + String bindingDataKey = SPRING_SECURITY_OAUTH2_BINDING_DATA + "_" + originalSpringSecurityState; + + Map bindingMap = new HashMap<>(); + bindingMap.put("bindState", bindingStateToken); + bindingMap.put("username", bindingUsernameStr); + + session.setAttribute(bindingDataKey, bindingMap); + log.info("📦 세션에 연동 데이터 저장 완료 (키: {}, 값: {})", bindingDataKey, bindingMap); + log.debug("Session attributes after save (detailed): {}", getSessionAttributesString(session)); + + // 저장된키 바로 확인 + Object storedData = session.getAttribute(bindingDataKey); + log.info("⭐ 세션에 방금 저장된 연동 데이터 확인: 키 = {}, 값 = {}", bindingDataKey, storedData); + + } else { + log.debug("📦 커스텀 연동 파라미터가 없거나 유효하지 않습니다. (bindingUsernameStr: {}, bindingStateToken: {})", bindingUsernameStr, bindingStateToken); + log.debug("Session attributes after save (no binding data stored): {}", getSessionAttributesString(session)); + } + + log.info("🚀 IdP로 리다이렉트될 때 사용될 state는 '{}'입니다. (이 값이 Google로 보내집니다)", originalSpringSecurityState); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest( + HttpServletRequest request, + HttpServletResponse response + ) { + HttpSession session = request.getSession(false); + log.debug("DEBUG: removeAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); + log.debug("DEBUG: removeAuthorizationRequest - Session attributes before removal: {}", getSessionAttributesString(session)); + + OAuth2AuthorizationRequest authorizationRequest = delegate.removeAuthorizationRequest(request, response); + + log.debug("DEBUG: removeAuthorizationRequest - Session attributes after removal: {}", getSessionAttributesString(session)); + + return authorizationRequest; + } + + private String getSessionAttributesString(HttpSession session) { + if (session == null) { + return "[]"; + } + StringBuilder attributes = new StringBuilder("["); + Enumeration attributeNames = session.getAttributeNames(); + while (attributeNames.hasMoreElements()) { + attributes.append(attributeNames.nextElement()); + if (attributeNames.hasMoreElements()) { + attributes.append(", "); + } + } + attributes.append("]"); + return attributes.toString(); + } +} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberAuthProviderRepository.java b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberAuthProviderRepository.java new file mode 100644 index 00000000..7437dc33 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberAuthProviderRepository.java @@ -0,0 +1,32 @@ +package sequence.sequence_member.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import sequence.sequence_member.global.enums.enums.AuthProvider; +import sequence.sequence_member.member.entity.MemberAuthProvider; + +import java.util.Optional; + +@Repository +public interface MemberAuthProviderRepository extends JpaRepository { + + /** + * 특정 회원 ID와 제공자(Provider)로 MemberAuthProvider를 조회합니다. + * 계정 연동 시 해당 회원이 이미 해당 제공자로 연동되어 있는지 확인할 때 사용합니다. + * + * @param memberId 조회할 회원 ID + * @param provider 소셜 로그인 제공자 타입 (예: AuthProvider.GOOGLE) + * @return MemberAuthProvider 객체 (존재하지 않으면 Optional.empty()) + */ + Optional findByMemberIdAndProvider(Long memberId, AuthProvider provider); + + /** + * 특정 제공자(Provider)와 해당 제공자의 사용자 고유 ID(providerId)로 MemberAuthProvider를 조회합니다. + * 소셜 로그인 시 기존 가입자를 찾거나, 새로운 연동을 시도할 때 해당 소셜 계정이 이미 다른 회원에게 연동되어 있는지 확인할 때 사용합니다. + * + * @param provider 소셜 로그인 제공자 타입 (예: AuthProvider.GOOGLE) + * @param providerId 소셜 로그인 제공자의 사용자 고유 ID + * @return MemberAuthProvider 객체 (존재하지 않으면 Optional.empty()) + */ + Optional findByProviderAndProviderId(AuthProvider provider, String providerId); +} \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java index 1b2d7462..7e5ca9f6 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java @@ -11,8 +11,6 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; -import sequence.sequence_member.global.enums.enums.AuthProvider; -import sequence.sequence_member.global.enums.enums.Status; import sequence.sequence_member.member.entity.MemberEntity; import java.util.Optional; @@ -59,8 +57,6 @@ WHEN m.nickname LIKE CONCAT(:nickname, '%') THEN 1 """) List searchMemberNicknames(@Param("nickname") String nickname, Pageable pageable); - Optional findByEmailAndProvider(String email, AuthProvider provider); - // 아이디 찾기 @Query(""" SELECT m FROM MemberEntity m diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOAuth2UserService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOAuth2UserService.java deleted file mode 100644 index 5e790f72..00000000 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOAuth2UserService.java +++ /dev/null @@ -1,84 +0,0 @@ -package sequence.sequence_member.member.service; - -import jakarta.transaction.Transactional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; -import sequence.sequence_member.global.enums.enums.AuthProvider; -import sequence.sequence_member.member.dto.MemberPrincipal; -import sequence.sequence_member.member.entity.MemberEntity; -import sequence.sequence_member.member.oauth.OAuth2UserInfo; -import sequence.sequence_member.member.oauth.OAuth2UserInfoFactory; -import sequence.sequence_member.member.repository.MemberRepository; - -@Service -@Transactional -public class CustomOAuth2UserService implements OAuth2UserService { - - private static final Logger logger = LoggerFactory.getLogger(CustomOAuth2UserService.class); - - private final MemberRepository memberRepository; - - public CustomOAuth2UserService(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); - OAuth2User oauth2User = delegate.loadUser(userRequest); - - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - - // OAuth2 사용자 정보 파싱 - OAuth2UserInfo oauth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo( - registrationId, oauth2User.getAttributes()); - - if (oauth2UserInfo.getEmail() == null || oauth2UserInfo.getEmail().isEmpty()) { - throw new OAuth2AuthenticationException("OAuth2 provider에서 이메일을 찾을 수 없습니다."); - } - - AuthProvider provider = getAuthProvider(registrationId); - MemberEntity member = memberRepository.findByEmailAndProvider(oauth2UserInfo.getEmail(), provider) - .map(existing -> updateExistingMember(existing, oauth2UserInfo)) - .orElseGet(() -> registerNewMember(userRequest, oauth2UserInfo)); - - return MemberPrincipal.create(member, oauth2User.getAttributes()); - } - - private MemberEntity registerNewMember(OAuth2UserRequest userRequest, OAuth2UserInfo userInfo) { - AuthProvider provider = getAuthProvider(userRequest.getClientRegistration().getRegistrationId()); - - MemberEntity member = MemberEntity.createSocialMember( - userInfo.getEmail(), - userInfo.getName() != null ? userInfo.getName() : "사용자", - provider, - userInfo.getId() - ); - - return memberRepository.save(member); - } - - private MemberEntity updateExistingMember(MemberEntity existing, OAuth2UserInfo userInfo) { - if (userInfo.getName() != null) { - existing.setName(userInfo.getName()); - } - return memberRepository.save(existing); - } - - private AuthProvider getAuthProvider(String registrationId) { - switch (registrationId.toUpperCase()) { - case "GOOGLE": - return AuthProvider.GOOGLE; -// case "KAKAO": -// return AuthProvider.KAKAO; - default: - throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인: " + registrationId); - } - } -} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java new file mode 100644 index 00000000..0b0503d9 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java @@ -0,0 +1,266 @@ +package sequence.sequence_member.member.service; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import sequence.sequence_member.global.enums.enums.AuthProvider; +import sequence.sequence_member.member.dto.MemberPrincipal; +import sequence.sequence_member.member.entity.MemberAuthProvider; +import sequence.sequence_member.member.entity.MemberEntity; +import sequence.sequence_member.member.oauth.OAuth2UserInfo; +import sequence.sequence_member.member.oauth.OAuth2UserInfoFactory; +import sequence.sequence_member.member.repository.MemberAuthProviderRepository; +import sequence.sequence_member.member.repository.MemberRepository; + +import java.util.Enumeration; +import java.util.Map; +import java.util.Optional; + +import static sequence.sequence_member.member.repository.CustomAuthorizationRequestRepository.SPRING_SECURITY_OAUTH2_BINDING_DATA; + +@Service +@Transactional +public class CustomOidcUserService implements OAuth2UserService { + + private static final Logger logger = LoggerFactory.getLogger(CustomOidcUserService.class); + + private final MemberRepository memberRepository; + private final MemberAuthProviderRepository memberAuthProviderRepository; // 의존성 주입 + + public CustomOidcUserService(MemberRepository memberRepository, MemberAuthProviderRepository memberAuthProviderRepository) { + this.memberRepository = memberRepository; + this.memberAuthProviderRepository = memberAuthProviderRepository; + } + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + logger.info("✅ CustomOidcUserService loadUser 호출됨"); + OidcUserService delegate = new OidcUserService(); + OidcUser oidcUser = delegate.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + logger.debug("DEBUG: OidcUser Attributes: {}", oidcUser.getAttributes()); + + OAuth2UserInfo oauth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oidcUser.getAttributes()); + + logger.debug("DEBUG: OAuth2UserInfo Extracted Email: {}", oauth2UserInfo.getEmail()); + + if (oauth2UserInfo.getEmail() == null || oauth2UserInfo.getEmail().isEmpty()) { + logger.error("ERROR: OAuth2UserInfo에서 이메일이 null이거나 비어있습니다. 예외 발생."); + throw new OAuth2AuthenticationException("OAuth2 provider에서 이메일을 찾을 수 없습니다."); + } + + AuthProvider provider = getAuthProvider(registrationId); // registrationId로 AuthProvider 가져옴 + + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + HttpServletRequest request = null; + if (attributes != null) { + request = attributes.getRequest(); + } + if (request == null) { + throw new IllegalStateException("HttpServletRequest not available in RequestContextHolder."); + } + + logger.info("Session ID (load): {}", request.getSession().getId()); + StringBuilder sessionAttributes = new StringBuilder("["); + Enumeration attributeNames = request.getSession().getAttributeNames(); + while (attributeNames.hasMoreElements()) { + sessionAttributes.append(attributeNames.nextElement()); + if (attributeNames.hasMoreElements()) { + sessionAttributes.append(", "); + } + } + sessionAttributes.append("]"); + logger.debug("Session attributes before extract: {}", sessionAttributes); + + String returnedSpringSecurityState = request.getParameter("state"); + logger.info("반환된 state (IdP로부터): {}", returnedSpringSecurityState); + + String BIND_STATE_PREFIX = "bind:"; + + boolean isBindingRequest = false; + Map bindingData = null; + + if (returnedSpringSecurityState != null) { + // CustomAuthorizationRequestRepository에서 저장한 바인딩 데이터를 세션에서 조회 + bindingData = (Map) request.getSession().getAttribute( + SPRING_SECURITY_OAUTH2_BINDING_DATA + "_" + returnedSpringSecurityState + ); + + if (bindingData != null) { + String bindStateFromMap = (String) bindingData.get("bindState"); + String usernameFromMap = (String) bindingData.get("username"); + + if (bindStateFromMap != null && bindStateFromMap.startsWith(BIND_STATE_PREFIX) && usernameFromMap != null) { + isBindingRequest = true; + } + // 세션에서 사용한 바인딩 데이터 제거 (한번 사용하면 제거) + request.getSession().removeAttribute( + SPRING_SECURITY_OAUTH2_BINDING_DATA + "_" + returnedSpringSecurityState + ); + logger.debug("DEBUG: Removed binding data map from session in CustomOidcUserService."); + } + } + + logger.info("추출된 연동용 데이터: {}", bindingData); + logger.info("연동 요청 여부: {}", isBindingRequest); + + MemberEntity member; + if (isBindingRequest && bindingData != null) { + member = bindSocialAccount(bindingData, oauth2UserInfo, provider); + } else { + member = loginOrRegister(oauth2UserInfo, provider, userRequest); + } + + logger.debug("DEBUG: Member created/retrieved Email (before MemberPrincipal.create): {}", member.getEmail()); + + return MemberPrincipal.create(member, oidcUser.getAttributes(), oidcUser.getIdToken(), oidcUser.getUserInfo()); + } + + /** + * 기존 회원에게 소셜 계정을 연동하는 로직 + * @param bindingData 세션에서 추출된 연동 관련 데이터 (username, bindState) + * @param userInfo 소셜 제공자로부터 받은 사용자 정보 + * @param provider 소셜 제공자 타입 + * @return 연동 완료된 MemberEntity + * @throws OAuth2AuthenticationException 계정 연동 실패 시 발생 + */ + private MemberEntity bindSocialAccount( + Map bindingData, + OAuth2UserInfo userInfo, + AuthProvider provider + ) throws OAuth2AuthenticationException { + String bindState = (String) bindingData.get("bindState"); + String username = (String) bindingData.get("username"); + + if (username == null || bindState == null) { + logger.warn("WARN: 계정 연동을 위한 데이터가 불완전합니다. (username: {}, bindState: {})", username, bindState); + throw new OAuth2AuthenticationException( + new OAuth2Error("INVALID_BINDING_REQUEST"), + "계정 연동을 위한 데이터가 불완전합니다. 다시 시도해주세요." + ); + } + logger.debug("DEBUG: bindSocialAccount에서 가져온 username: {} (bindState: {})", username, bindState); + + Optional memberOptional = memberRepository.findByUsernameAndIsDeletedFalse(username); + MemberEntity member = memberOptional.orElse(null); + + if (member == null) { + logger.warn("WARN: 연동을 시도한 사용자를 찾을 수 없습니다. username: {}", username); + throw new OAuth2AuthenticationException( + new OAuth2Error("USER_NOT_FOUND"), + "연동하려는 계정을 찾을 수 없습니다." + ); + } + + // 이미 연동된 구글 계정 유무 확인 + Optional existingSocialLinkToAnyMember = memberAuthProviderRepository.findByProviderAndProviderId(provider, userInfo.getId()); + if (existingSocialLinkToAnyMember.isPresent()) { + if (!existingSocialLinkToAnyMember.get().getMember().getId().equals(member.getId())) { + logger.warn("WARN: 소셜 계정({}, {})이 이미 다른 회원({})에게 연동되어 있습니다.", provider, userInfo.getId(), existingSocialLinkToAnyMember.get().getMember().getEmail()); + throw new OAuth2AuthenticationException( + new OAuth2Error("SOCIAL_ACCOUNT_ALREADY_LINKED"), + "이미 다른 계정에 연동된 소셜 계정입니다. 해당 계정으로 로그인해주세요." + ); + } else { + logger.info("INFO: 소셜 계정({}, {})이 이미 현재 로그인된 회원({})에게 연동되어 있습니다. 재로그인 처리.", provider, userInfo.getId(), member.getEmail()); + return member; + } + } + + // 새로운 MemberAuthProvider 생성 및 Member에 추가 + MemberAuthProvider authProvider = new MemberAuthProvider( + member, + provider, + userInfo.getId() + ); + member.addAuthProviderIfNotExists(authProvider); + memberRepository.save(member); + + logger.info("🔗 계정 연동 완료: username = {}, Provider: {}", username, provider); + return member; + } + + /** + * 소셜 로그인 또는 신규 회원 등록 로직 + * @param userInfo 소셜 제공자로부터 받은 사용자 정보 + * @param provider 소셜 제공자 타입 + * @param userRequest OIDC 사용자 요청 객체 + * @return 로그인 또는 등록된 MemberEntity + */ + private MemberEntity loginOrRegister( + OAuth2UserInfo userInfo, + AuthProvider provider, + OidcUserRequest userRequest + ) { + Optional existingAuthProviderOptional = memberAuthProviderRepository.findByProviderAndProviderId(provider, userInfo.getId()); + + MemberEntity member; + if (existingAuthProviderOptional.isPresent()) { + member = existingAuthProviderOptional.get().getMember(); + logger.info("[소셜 계정 로그인] 기존 사용자 로그인: email={}, Provider: {}", member.getEmail(), provider); + } else { + member = registerNewMember(userRequest, userInfo); + logger.info("[소셜 계정 로그인] 새로운 사용자 등록: email={}, Provider: {}", member.getEmail(), provider); + } + + return member; + } + + /** + * 새로운 소셜 로그인 회원을 등록하는 로직 + * @param userRequest OIDC 사용자 요청 객체 + * @param oAuth2UserInfo OAuth2UserInfo (표준화된 사용자 정보) + * @return 새로 등록된 MemberEntity + */ + private MemberEntity registerNewMember(OidcUserRequest userRequest, OAuth2UserInfo oAuth2UserInfo) { + AuthProvider provider = getAuthProvider(userRequest.getClientRegistration().getRegistrationId()); + + MemberEntity member = MemberEntity.createSocialMember( + oAuth2UserInfo.getEmail(), // 이메일은 OAuth2UserInfoFactory에서 검증됨 + oAuth2UserInfo.getName() != null ? oAuth2UserInfo.getName() : "사용자" + ); + + MemberAuthProvider memberAuthProvider = new MemberAuthProvider( + member, + provider, + oAuth2UserInfo.getId() + ); + member.addAuthProviderIfNotExists(memberAuthProvider); + + logger.info("새로운 소셜 로그인 사용자 등록 완료: email={}, Provider: {}", member.getEmail(), provider); + return memberRepository.save(member); + } + + /** + * registrationId (clientRegistration.registrationId)로부터 AuthProvider Enum 값을 가져옵니다. + * @param registrationId 클라이언트 등록 ID (예: "google", "kakao") + * @return 해당 AuthProvider Enum 값 + * @throws OAuth2AuthenticationException 지원하지 않는 제공자일 경우 발생 + */ + private AuthProvider getAuthProvider(String registrationId) throws OAuth2AuthenticationException { + switch (registrationId.toUpperCase()) { + case "GOOGLE": + return AuthProvider.GOOGLE; + // case "KAKAO": + // return AuthProvider.KAKAO; + default: + logger.warn("WARN: 지원하지 않는 소셜 로그인 서비스입니다: {}", registrationId); + throw new OAuth2AuthenticationException( + new OAuth2Error("UNSUPPORTED_SOCIAL_PROVIDER"), + "지원하지 않는 소셜 로그인 서비스입니다: " + registrationId + ); + } + } +} \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java index f2c93df8..1222895d 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java @@ -3,27 +3,29 @@ import java.time.LocalDateTime; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import sequence.sequence_member.member.entity.EmailAuthTokenEntity; import sequence.sequence_member.member.repository.EmailAuthTokenRepository; -import org.springframework.beans.factory.annotation.Value; - @RequiredArgsConstructor @Service public class EmailAuthService { private final JavaMailSender mailSender; + private final EmailAuthTokenRepository tokenRepo; + private final SpringTemplateEngine templateEngine; @Value("${NAVER_MAIL_USERNAME:dev_mj_@naver.com}") private String fromEmail; - private final EmailAuthTokenRepository tokenRepo; public void requestEmailVerification(String email) { tokenRepo.findAllByEmail(email).forEach(token -> { @@ -31,7 +33,7 @@ public void requestEmailVerification(String email) { tokenRepo.save(token); }); - String token = UUID.randomUUID().toString().substring(0, 6); + String token = UUID.randomUUID().toString().substring(0, 6).toUpperCase(); EmailAuthTokenEntity emailAuthToken = EmailAuthTokenEntity.builder() .email(email) @@ -41,11 +43,9 @@ public void requestEmailVerification(String email) { .build(); tokenRepo.save(emailAuthToken); - sendAuthEmail(email, token); } - // 인증 확인 public void verifyEmailToken(String email, String token) { EmailAuthTokenEntity authToken = tokenRepo.findByEmailAndToken(email, token) .orElseThrow(() -> new IllegalArgumentException("잘못된 인증 정보입니다.")); @@ -61,42 +61,33 @@ public void verifyEmailToken(String email, String token) { throw new IllegalStateException("시간 초과로 인하여 토큰이 만료되었습니다."); } - // 해당 이메일의 모든 토큰 만료 처리 tokenRepo.findAllByEmail(email).forEach(t -> { - t.setVerified(false); // 기존 것들 다 false로 초기화 + t.setVerified(false); tokenRepo.save(t); }); - // 현재 토큰만 인증 처리 authToken.setVerified(true); tokenRepo.save(authToken); } - - - // MimeMessage 방식으로 인증 메일 전송 private void sendAuthEmail(String email, String token) { try { MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, true); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + Context context = new Context(); + context.setVariable("token", token); + + String htmlContent = templateEngine.process("emailAuth", context); helper.setFrom(fromEmail); helper.setTo(email); - helper.setSubject("[Sequence] 이메일 인증 코드"); + helper.setSubject("[Sequence] 이메일 인증 안내"); + helper.setText(htmlContent, true); - String content = "" - + "

이메일 인증 안내

" - + "

아래의 인증 코드를 입력해주세요.

" - + "

인증 코드: " + token + "

" - + ""; - - helper.setText(content, true); mailSender.send(message); - } catch (MessagingException e) { - throw new RuntimeException("이메일 전송 실패", e); + throw new RuntimeException("이메일 발송 중 오류가 발생했습니다.", e); } } - } - diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java index 78d2b0ba..4f106d90 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java @@ -16,7 +16,6 @@ import sequence.sequence_member.member.entity.*; import sequence.sequence_member.member.repository.*; - import java.util.*; @Service @@ -65,7 +64,13 @@ public void save(MemberDTO memberDTO, MultipartFile authImgFile, List> getMyProfile( - @RequestParam(defaultValue = "0") int page, // 페이지 기본값 0 - @RequestParam(defaultValue = "10") int size, // 사이즈 기본값 10 @AuthenticationPrincipal CustomUserDetails customUserDetails ) { String username = customUserDetails.getUsername(); try { - MyPageResponseDTO myPageDTO = myPageService.getMyProfile(username, page, size, customUserDetails); + MyPageResponseDTO myPageDTO = myPageService.getMyProfile(username, customUserDetails); // 성공 응답 생성 return ResponseEntity.ok(ApiResponseData.success(myPageDTO, "사용자 정보를 성공적으로 가져왔습니다.")); } catch (Exception e) { @@ -73,12 +70,10 @@ public ResponseEntity> updateMyProfile( @GetMapping("/api/mypage/{nickname}") public ResponseEntity> getUserProfile( @PathVariable String nickname, - @RequestParam(defaultValue = "0") int page, // 페이지 기본값 0 - @RequestParam(defaultValue = "10") int size, // 사이즈 기본값 10 @AuthenticationPrincipal CustomUserDetails customUserDetails ) { try { - MyPageResponseDTO userProfile = myPageService.getUserProfile(nickname, page, size, customUserDetails); + MyPageResponseDTO userProfile = myPageService.getUserProfile(nickname, customUserDetails); return ResponseEntity.ok(ApiResponseData.success(userProfile, nickname + "님의 정보를 성공적으로 가져왔습니다.")); } catch (Exception e) { ApiResponseData errorResponse = ApiResponseData.failure( diff --git a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java index dc131312..e68de36b 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java +++ b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java @@ -1,7 +1,6 @@ package sequence.sequence_member.mypage.dto; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import sequence.sequence_member.archive.dto.MyPageEvaluationDTO; @@ -47,18 +46,18 @@ public class MyPageMapper { * 멤버와 아카이브 페이지네이션 객체를 ResponseDTO 매핑하는 메인 함수입니다. * * @param member ResponseDTO로 매핑할 멤버 객체 - * @param archivePage ResponseDTO로 매핑할 archive 페이지네이션 객체 + * @param archiveList ResponseDTO로 매핑할 archive 페이지네이션 객체 * @return 사용자의 마이페이지 정보를 담은 MyPageResponseDTO */ public MyPageResponseDTO toMyPageResponseDto( MemberEntity member, - Page archivePage, + List archiveList, List invitedProjects ) { return new MyPageResponseDTO( toBasicInfoDto(member), toCareerHistoryDto(member), - toPortfolioDto(archivePage, invitedProjects), + toPortfolioDto(archiveList, invitedProjects), toTeamFeedbackDto(member), getMyActivity(member) ); @@ -140,17 +139,19 @@ private CareerHistoryDTO toCareerHistoryDto(MemberEntity member) { /** * 내가 작성한 아카이브와 초대받은 프로젝트 리스트를 PortfolioDTO로 변환합니다. * - * @param archivePage + * @param archiveList * @return PortfolioDto 객체 */ - private PortfolioDTO toPortfolioDto(Page archivePage, List invitedProjects) { - Page archiveDTO = archivePage.map(archive -> ArchiveSummaryDTO.builder() - .id(archive.getId()) - .title(archive.getTitle()) - .thumbnail(archive.getThumbnail()) - .startDate(archive.getStartDate()) - .endDate(archive.getEndDate()) - .build()); + private PortfolioDTO toPortfolioDto(List archiveList, List invitedProjects) { + List archiveDTO = archiveList.stream() + .map(archive -> ArchiveSummaryDTO.builder() + .id(archive.getId()) + .title(archive.getTitle()) + .thumbnail(archive.getThumbnail()) + .startDate(archive.getStartDate()) + .endDate(archive.getEndDate()) + .build()) + .collect(Collectors.toList()); return new PortfolioDTO(archiveDTO, invitedProjects); } diff --git a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java index 4484dc10..26b5d267 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java @@ -3,19 +3,17 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.domain.Page; import java.util.List; /** * 사용자가 참여한 아카이브 이력 DTO - * * 마이페이지 화면에서 '포트폴리오'에 해당하는 객체 */ @Getter @AllArgsConstructor @NoArgsConstructor public class PortfolioDTO { - private Page archivePage; + private List archiveList; private List invitedProjects; } diff --git a/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java b/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java index bb4b3bba..fdc75f93 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java @@ -3,10 +3,6 @@ import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -15,6 +11,7 @@ import org.springframework.web.multipart.MultipartFile; import sequence.sequence_member.archive.entity.Archive; import sequence.sequence_member.archive.repository.ArchiveRepository; +import sequence.sequence_member.global.enums.enums.Status; import sequence.sequence_member.global.exception.BAD_REQUEST_EXCEPTION; import sequence.sequence_member.global.response.ApiResponseData; import sequence.sequence_member.global.response.Code; @@ -48,24 +45,19 @@ public class MyPageService { * 주어진 사용자명(username)에 해당하는 마이페이지 정보를 조회합니다. * * @param username 조회할 사용자의 이름 - * @param page archive 페이지네이션 파라미터 - * @param size archive 페이지네이션 파라미터 * @param customUserDetails 포트폴리오 객체에서 사용하는 파라미터 * * @return 사용자의 마이페이지 정보를 담은 DTO * @throws EntityNotFoundException 사용자를 찾을 수 없는 경우 발생 */ - public MyPageResponseDTO getMyProfile(String username, int page, int size, CustomUserDetails customUserDetails) { + public MyPageResponseDTO getMyProfile(String username, CustomUserDetails customUserDetails) { MemberEntity member = memberRepository.findByUsernameAndIsDeletedFalse(username) .orElseThrow(() -> new EntityNotFoundException("해당 사용자를 찾을 수 없습니다.")); - Pageable pageable = PageRequest.of(page, size, Sort.by("createdDateTime").descending()); - Page archivePage = archiveRepository.findByWriterAndIsDeletedFalse(member, pageable); - + List archiveList = archiveRepository.findTop5ByMemberIdAndStatus(member.getId(), Status.평가완료); List invitedProjects = getInvitedProjects(customUserDetails); - return myPageMapper.toMyPageResponseDto(member, archivePage, invitedProjects); - + return myPageMapper.toMyPageResponseDto(member, archiveList, invitedProjects); } /** @@ -99,18 +91,16 @@ public void updateMyProfile( * @return 초대받은 프로젝트 정보와 각 프로젝트의 댓글 수를 담은 DTO 리스트 * @throws EntityNotFoundException 사용자를 찾을 수 없는 경우 발생 */ - public MyPageResponseDTO getUserProfile(String nickname, int page, int size, CustomUserDetails customUserDetails) { + public MyPageResponseDTO getUserProfile(String nickname, CustomUserDetails customUserDetails) { MemberEntity member = memberRepository.findByNickname(nickname) .orElseThrow(() -> new EntityNotFoundException("해당 사용자를 찾을 수 없습니다.")); if(member.isDeleted()) throw new BAD_REQUEST_EXCEPTION("탈퇴한 사용자입니다."); - Pageable pageable = PageRequest.of(page, size, Sort.by("createdDateTime").descending()); - Page archivePage = archiveRepository.findByWriterAndIsDeletedFalse(member, pageable); - + List archiveList = archiveRepository.findTop5ByMemberIdAndStatus(member.getId(), Status.평가완료); List invitedProjects = getInvitedProjects(customUserDetails); - return myPageMapper.toMyPageResponseDto(member, archivePage, invitedProjects); + return myPageMapper.toMyPageResponseDto(member, archiveList, invitedProjects); } /** diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java b/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java index ce84f04c..9c1cae2b 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java @@ -1,6 +1,7 @@ package sequence.sequence_member.project.controller; 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; @@ -16,6 +17,7 @@ import sequence.sequence_member.project.dto.CommentUpdateDTO; import sequence.sequence_member.project.service.CommentService; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/projects/{projectId}/comments") @@ -26,20 +28,29 @@ public class CommentController { @PostMapping public ResponseEntity> writeComment(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long projectId, @RequestBody CommentInputDTO commentInputDTO){ + log.info("프로젝트 댓글 등록 요청 : /api/projects/{projectId}/comments POST request 발생"); + commentService.writeComment(customUserDetails, projectId , commentInputDTO); + return ResponseEntity.ok().body(ApiResponseData.success(null, "댓글 작성 성공")); } @PutMapping("/{commentId}") public ResponseEntity> updateComment(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long projectId, @PathVariable Long commentId, @RequestBody CommentUpdateDTO commentUpdateDTO){ + log.info("프로젝트 댓글 수정 요청 : /api/projects/{projectId}/comments/{commentId} PUT request 발생"); + commentService.updateComment(customUserDetails, projectId, commentId, commentUpdateDTO); + return ResponseEntity.ok().body(ApiResponseData.success(null, "댓글 수정 성공")); } @DeleteMapping("/{commentId}") public ResponseEntity> deleteComment(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long projectId, @PathVariable Long commentId){ + log.info("프로젝트 댓글 삭제 요청 : /api/projects/{projectId}/comments/{commentId} DELETE request 발생"); + commentService.deleteComment(customUserDetails, projectId, commentId); + return ResponseEntity.ok().body(ApiResponseData.success(null, "댓글 삭제 성공")); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java b/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java index 80fb095b..dfbccf9b 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java @@ -41,24 +41,34 @@ public class ProjectController { @PostMapping() public ResponseEntity> registerProject(@Valid @RequestBody ProjectInputDTO projectInputDTO, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + log.info("프로젝트 등록 요청 : /api/projects POST request 발생"); + projectCreateService.createProject(projectInputDTO, customUserDetails.getUsername()); + return ResponseEntity.ok(ApiResponseData.success(null, "프로젝트 등록 성공")); } @GetMapping("/{projectId}") public ResponseEntity> getProject(@PathVariable("projectId") Long projectId, HttpServletRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + log.info("프로젝트 상세 조회 요청 : /api/projects/{projectId} GET request 발생"); + return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "프로젝트 조회 성공", projectGetService.getProject(projectId, request, customUserDetails))); } @PutMapping("/{projectId}") public ResponseEntity> updateProject(@PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestBody ProjectUpdateDTO projectUpdateDTO, HttpServletRequest request){ + log.info("프로젝트 수정 요청 : /api/projects/{projectId} PUT request 발생"); + return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "프로젝트 수정 성공",projectService.updateProject(projectId, customUserDetails, projectUpdateDTO,request))); } @DeleteMapping("/{projectId}") public ResponseEntity> deleteProject(@PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails){ + log.info("프로젝트 삭제 요청 : /api/projects/{projectId} DELETE request 발생"); + projectService.deleteProject(projectId, customUserDetails); + return ResponseEntity.ok().body(ApiResponseData.success(null, "프로젝트 삭제 성공")); } @@ -68,6 +78,8 @@ public ResponseEntity> deleteProject(@PathVariable("proj // 북마크 등록 @PostMapping("/{projectId}/bookmark") public ResponseEntity> addProjectBookmark(@PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails){ + log.info("프로젝트 북마크 등록 요청 : /api/projects/{projectId}/bookmark POST request 발생"); + return ResponseEntity.ok().body(ApiResponseData.success(null,projectBookmarkService.addBookmark(customUserDetails, projectId))); } // 북마크 삭제 @@ -75,6 +87,8 @@ public ResponseEntity> addProjectBookmark(@PathVariable( public ResponseEntity> removeProjectBookmark( @PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + log.info("프로젝트 북마크 삭제 요청 : /api/projects/{projectId}/bookmark DELETE request 발생"); + return ResponseEntity.ok().body(ApiResponseData.success(null, projectBookmarkService.removeBookmark(customUserDetails, projectId))); } @@ -88,6 +102,8 @@ public ResponseEntity> filterKeyword(@Re @RequestParam(name="sortBy", required = false, defaultValue = "createdDateTime") String sortBy, @RequestParam(name="page", required = false, defaultValue = "0") int page, @RequestParam(name="size", required = false, defaultValue = "12") int size){ + log.info("프로젝트 키워트 필터링 요청 : /api/projects/filter/keyword GET request 발생"); + Page projectFilterOutputDTOS = projectService.getProjectsByKeywords(category,periodKey,roles,skills,meetingOption,step,sortBy,page,size); //조회된 프로젝트가 하나도 없는 경우 @@ -104,15 +120,21 @@ public ResponseEntity> filterSearch(@Req @RequestParam(name="sortBy", required = false, defaultValue = "createdDateTime") String sortBy, @RequestParam(name="page", required = false, defaultValue = "0") int page, @RequestParam(name="size", required = false, defaultValue = "12") int size){ + log.info("프로젝트 검색 필터링 요청 : /api/projects/filter/search GET request 발생"); + Page projectFilterOutputDTOS = projectService.getProjectsBySearch(title, sortBy, page, size); + if(projectFilterOutputDTOS.isEmpty()){ return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "검색어와 일치하는 프로젝트가 없습니다.",ProjectFilterResultDTO.of(0, 0L,projectFilterOutputDTOS.getContent()))); } + return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "프로젝트 조회가 완료되었습니다.",ProjectFilterResultDTO.of(projectFilterOutputDTOS.getTotalPages(), projectFilterOutputDTOS.getTotalElements(),projectFilterOutputDTOS.getContent()))); } @GetMapping("/list") public ResponseEntity>> findProjects(){ + log.info("프로젝트 전체 조회 요청 : /api/projects/list GET request 발생"); + List projectEntities = new ArrayList<>(projectService.getAllProjects()); if(projectEntities.isEmpty()){ diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java b/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java index 216fc07f..53f64a78 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java @@ -1,11 +1,13 @@ package sequence.sequence_member.project.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import sequence.sequence_member.project.service.RedisTestService; +@Slf4j @RestController @RequestMapping("/test") @RequiredArgsConstructor @@ -14,7 +16,10 @@ public class RedisTestController { @GetMapping("/redis") public String testRedis() { + log.info("레디스 테스트 요청 : /test/redis GET request 발생"); + redisTestService.testRedisConnection(); + return "Redis 테스트 완료!"; } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java index 7a7e8dd9..a1b68b02 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import sequence.sequence_member.global.exception.CanNotFindResourceException; @@ -15,6 +16,7 @@ import sequence.sequence_member.project.repository.ProjectBookmarkRepository; import sequence.sequence_member.project.repository.ProjectRepository; +@Slf4j @Service @RequiredArgsConstructor public class ProjectBookmarkService { @@ -31,9 +33,11 @@ public String addBookmark(CustomUserDetails customUserDetails, Long projectId) { if(member == null){ errMessgage.append("멤버를 찾을 수 없습니다.\n"); + log.error("멤버를 찾을 수 없음"); } if(project == null){ errMessgage.append("프로젝트를 찾을 수 없습니다.\n"); + log.error("프로젝트를 찾을 수 없음"); } if(!errMessgage.isEmpty()){ throw new CanNotFindResourceException(errMessgage.toString()); @@ -65,9 +69,11 @@ public String removeBookmark(CustomUserDetails customUserDetails, Long projectId if(member == null){ errMessgage.append("멤버를 찾을 수 없습니다.\n"); + log.error("멤버를 찾을 수 없음"); } if(project == null){ errMessgage.append("프로젝트를 찾을 수 없습니다.\n"); + log.error("프로젝트를 찾을 수 없음"); } if(!errMessgage.isEmpty()){ throw new CanNotFindResourceException(errMessgage.toString()); @@ -99,10 +105,12 @@ public void deleteByProject(Project project, String username){ // 프로젝트 북마크 여부 확인 ( 북마크 되어있으면 true, 아니면 false, 로그인 안한 사용자는 false) public boolean isBookmarked(Long projectId, CustomUserDetails customUserDetails) { if (customUserDetails == null) { + log.error("로그인 재시도 필요 : No customUserDetail here"); return false; } MemberEntity member = memberRepository.findByUsernameAndIsDeletedFalse(customUserDetails.getUsername()).orElse(null); if (member == null) { + log.error("멤버를 찾을 수 없음"); return false; } return bookmarkRepository.existsByMemberIdAndProjectId(member.getId(), projectId); diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java index 10534e11..870e8f2f 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java @@ -32,10 +32,15 @@ public class ProjectGetService { @Transactional(readOnly = true) public ProjectOutputDTO getProject(Long projectId, HttpServletRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails){ Project project = projectRepository.findById(projectId).orElseThrow(()-> new CanNotFindResourceException("해당 프로젝트가 존재하지 않습니다.")); + List projectMemberOutputDTOS = projectMemberService.getProjectMemberOutputDTOS(project); + List commentOutputDTOS = commentService.getCommentOutputDTOS(project); + int views = projectViewService.getViews(projectId, request, project); + boolean bookmarked = projectBookmarkService.isBookmarked(projectId, customUserDetails); + return ProjectOutputDTO.from(project,projectMemberOutputDTOS, commentOutputDTOS, views, bookmarked); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java index bf2284e1..7149cfd2 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +17,7 @@ import sequence.sequence_member.project.repository.ProjectInvitedMemberRepository; import sequence.sequence_member.project.repository.ProjectMemberRepository; +@Slf4j @Service @RequiredArgsConstructor public class ProjectMemberService { @@ -28,6 +30,7 @@ public class ProjectMemberService { @Transactional public void saveProjectInvitedMember(ProjectInputDTO projectInputDTO, MemberEntity writer, Project project) { if(projectInputDTO.getInvitedMembersNicknames()==null || projectInputDTO.getInvitedMembersNicknames().isEmpty()){ + log.error("멤버를 찾을 수 없음"); return; } projectInputDTO.getInvitedMembersNicknames().remove(writer.getNickname()); // 본인은 제거 @@ -54,7 +57,9 @@ public void saveProjectMember(Project project, MemberEntity member) { public List getProjectMemberOutputDTOS(Project project) { //Member정보중 memberId, nickname, profileImg만을 추출하여 응답데이터에 포함함 List projectMembers = project.getMembers(); + List projectMemberOutputDTOS = new ArrayList<>(); + for (ProjectMember projectMember : projectMembers) { projectMemberOutputDTOS.add(ProjectMemberOutputDTO.builder() .nickname(projectMember.getMember().getNickname()) diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java index 2914376c..64daf080 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java @@ -56,27 +56,35 @@ public class ProjectService { public ProjectOutputDTO updateProject(Long projectId, CustomUserDetails customUserDetails, ProjectUpdateDTO projectUpdateDTO,HttpServletRequest request) { Project project = projectRepository.findById(projectId) .orElseThrow(() -> new CanNotFindResourceException("해당 프로젝트가 존재하지 않습니다.")); + MemberEntity writer = memberRepository.findByUsernameAndIsDeletedFalse(customUserDetails.getUsername()) .orElseThrow(() -> new UserNotFindException("요청하는 유저가 존재하지 않습니다.")); + if (!project.getWriter().equals(writer)) { throw new AuthException("작성자만 수정할 수 있습니다."); } + // Project Entity에 ProjectInputDTO의 정보를 업데이트 project.updateProject(projectUpdateDTO); // 삭제된 멤버들은 ProjectMember에서 삭제 List deletedMembers = memberRepository.findByNicknameIn(projectUpdateDTO.getDeletedMembersNicknames()); + for(MemberEntity deletedMember : deletedMembers){ ProjectMember projectMember = projectMemberRepository.findByMemberIdAndProjectId( deletedMember.getId(), projectId); + if(projectMember==null){ log.error("삭제된 멤버가 프로젝트에 존재하지 않습니다."); continue; } + if(deletedMembers.contains(writer)){ throw new BAD_REQUEST_EXCEPTION("작성자는 멤버에서 삭제할 수 없습니다."); } + projectMember.softDelete(customUserDetails.getUsername()); + projectMemberRepository.save(projectMember); } @@ -105,11 +113,14 @@ public ProjectOutputDTO updateProject(Long projectId, CustomUserDetails customUs public void deleteProject(Long projectId, CustomUserDetails customUserDetails){ Project project = projectRepository.findById(projectId) .orElseThrow(() -> new CanNotFindResourceException("해당 프로젝트가 존재하지 않습니다.")); + MemberEntity writer = memberRepository.findByUsernameAndIsDeletedFalse(customUserDetails.getUsername()) .orElseThrow(() -> new UserNotFindException("해당 유저가 존재하지 않습니다.")); + if (!project.getWriter().equals(writer)) { throw new AuthException("작성자만 삭제할 수 있습니다."); } + project.softDelete(customUserDetails.getUsername()); //북마크 삭제 @@ -129,6 +140,7 @@ private void deleteProjectInvitedMember(Project project, List projectMe for(ProjectMember member : projectMembers){ member.softDelete(username); } + projectMemberRepository.saveAll(projectMembers); } @@ -214,7 +227,4 @@ public List getAllProjects(){ } - - - } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java index 523a9f2e..33c99484 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java @@ -28,7 +28,7 @@ public void projectViewBackUpToDB(){ Set keys = redisTemplate.keys("viewCount:*"); if (keys == null || keys.isEmpty()) { - log.info("저장된 조회수 데이터가 없습니다."); + log.error("저장된 조회수 데이터가 없습니다."); return; } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java index 713818f5..d7580bc7 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java @@ -5,12 +5,14 @@ import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import sequence.sequence_member.global.annotation.MethodDescription; import sequence.sequence_member.project.entity.Project; import sequence.sequence_member.project.repository.ProjectRepository; +@Slf4j @Service @RequiredArgsConstructor public class ProjectViewService { @@ -25,6 +27,7 @@ public int getViews(Long projectId, HttpServletRequest request, Project project) views = getViewsFromRedis(request, projectId); }catch (Exception e){ views = project.getViews()+1; + log.error("view 조회 에러 발생", e); } return views; } diff --git a/sequence_member/src/main/resources/application.yml b/sequence_member/src/main/resources/application.yml index 8c46be70..e5161b2c 100644 --- a/sequence_member/src/main/resources/application.yml +++ b/sequence_member/src/main/resources/application.yml @@ -57,13 +57,21 @@ spring: authorization-uri: ${GOOGLE_AUTHORIZATION_URI} token-uri: ${GOOGLE_TOKEN_URI} user-info-uri: ${GOOGLE_USER_INFO_URI} + server: + servlet: + session: + cookie: + same-site: None + secure: false # 로컬 HTTP 환경에서'false'로 설정 + httponly: true + data: redis: host: ${REDIS_HOST} port: ${REDIS_PORT} password: ${REDIS_PASSWORD} timeout: 6000ms - + minio: endpoint: ${MINIO_ENDPOINT} @@ -80,11 +88,20 @@ management: endpoints: web: exposure: - include: prometheus + include: "*" + # /actuator/ 이하의 모든 엔드포인트 노출 설정 endpoint: + health: + show-details: always + # 이 설정은 /actuator/health 엔드포인트에서 헬스 체크 정보를 항상 상세히 보여주도록 설정합니다. 기본적으로, 헬스 체크 엔드포인트는 요약된 상태 정보만 제공하며, 상세 정보는 노출되지 않습니다. prometheus: enabled: true + + prometheus: + metrics: + export: + enabled: true server: tomcat: mbeanregistry: - enabled: true \ No newline at end of file + enabled: true diff --git a/sequence_member/src/main/resources/prometheus/prometheus.yml b/sequence_member/src/main/resources/prometheus/prometheus.yml new file mode 100644 index 00000000..b3307933 --- /dev/null +++ b/sequence_member/src/main/resources/prometheus/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'spring-boot' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080'] \ No newline at end of file diff --git a/sequence_member/src/main/resources/templates/emailAuth.html b/sequence_member/src/main/resources/templates/emailAuth.html new file mode 100644 index 00000000..34032636 --- /dev/null +++ b/sequence_member/src/main/resources/templates/emailAuth.html @@ -0,0 +1,43 @@ + + + + + Sequence 이메일 인증 + + + + + + + +
+ + + + + + + + + + +
+

Sequence

+
+

이메일 인증 안내

+

회원가입을 완료하려면 아래 인증 코드를 입력해주세요.

+ + + + + +
+ A1B2C3 +
+

본인이 요청하지 않은 인증 메일이라면 이 메일을 무시하셔도 좋습니다.

+
+

© 2025 Sequence. All Rights Reserved.

+
+
+ +