diff --git a/src/main/java/univ/yesummit/domain/board/api/dto/request/BoardSaveReqDto.java b/src/main/java/univ/yesummit/domain/board/api/dto/request/BoardSaveReqDto.java new file mode 100644 index 0000000..9c04fd9 --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/api/dto/request/BoardSaveReqDto.java @@ -0,0 +1,25 @@ +package univ.yesummit.domain.board.api.dto.request; + +import univ.yesummit.domain.board.domain.Board; +import univ.yesummit.domain.member.entity.Member; + +import java.util.List; + +public record BoardSaveReqDto( + String title, + + String content, + List imageUrl, + String serviceUrl, + String PTUrl +) { + public Board toEntity(Member member) { + return Board.builder() + .title(title) + .content(content) + .writer(member) + .serviceUrl(serviceUrl) + .PTUrl(PTUrl) + .build(); + } +} diff --git a/src/main/java/univ/yesummit/domain/board/api/dto/request/BoardUpdateReqDto.java b/src/main/java/univ/yesummit/domain/board/api/dto/request/BoardUpdateReqDto.java new file mode 100644 index 0000000..a8036b3 --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/api/dto/request/BoardUpdateReqDto.java @@ -0,0 +1,12 @@ +package univ.yesummit.domain.board.api.dto.request; + +import java.util.List; + +public record BoardUpdateReqDto( + String title, + String content, + List newImageUrl, + String serviceUrl, + String PTUrl +) { +} diff --git a/src/main/java/univ/yesummit/domain/board/domain/Board.java b/src/main/java/univ/yesummit/domain/board/domain/Board.java new file mode 100644 index 0000000..035be9d --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/Board.java @@ -0,0 +1,110 @@ +package univ.yesummit.domain.board.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import univ.yesummit.domain.board.api.dto.request.BoardUpdateReqDto; +import univ.yesummit.domain.comment.domain.Comment; +import univ.yesummit.domain.member.entity.Member; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Board { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "board_id") + @Schema(description = "피칭 게시글 id", example = "1") + private Long boardId; + + // 게시글 써밋 아이디 필드 만들기 (숫자만 저장하는 컬럼) + + @Schema(description = "피칭 제목", example = "제목") + @NotNull(message = "필수 입력 항목입니다.") + private String title; + + @Schema(description = "피칭 요약", example = "요약") + @Column(columnDefinition = "TEXT") + @NotNull(message = "필수 입력 항목입니다.") + private String content; + + @Schema(description = "서비스 웹사이트 혹은 기타 링크(선택)", example = "사이트 링크") + @Column(columnDefinition = "TEXT") + private String serviceUrl; + + @Schema(description = "PT 영상 링크", example = "영상 링크") + @Column(columnDefinition = "TEXT") + @NotNull(message = "필수 입력 항목입니다.") + private String PTUrl; + + @Schema(description = "게시글 날짜", example = "2024.06.21") + private String boardDate; + + @Schema(description = "좋아요 개수", example = "1") + private int likeCount; + + @Schema(description = "투자 제안한 횟수", example = "1") + private int investmentCount; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + @Schema(description = "작성자", example = "nickname") + private Member writer; + + @OneToMany(mappedBy = "board", orphanRemoval = true, cascade = CascadeType.ALL) + @Schema(description = "이미지") + private List pictures = new ArrayList<>(); + + @OneToMany(mappedBy = "board", orphanRemoval = true, cascade = CascadeType.ALL) + private List comments = new ArrayList<>(); + + @Builder + private Board(String title, String content, String serviceUrl, String PTUrl, Member writer) { + this.title = title; + this.content = content; + this.serviceUrl = serviceUrl; + this.PTUrl = PTUrl; + this.boardDate = String.valueOf(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + this.likeCount = 0; + this.writer = writer; + } + + + public void boardUpdate(BoardUpdateReqDto boardUpdateReqDto) { + this.title = boardUpdateReqDto.title(); + this.content = boardUpdateReqDto.content(); + } + + public void updateLikeCount() { + this.likeCount++; + } + public void updateInvestmentCount() { + this.investmentCount++; + } + + public void cancelLikeCount() { + if (this.likeCount <= 0) { + this.likeCount = 0; + } else { + this.likeCount--; + } + } + public void cancelInvestmentCount() { + if (this.investmentCount <= 0) { + this.investmentCount = 0; + } else { + this.investmentCount--; + } + } + +} \ No newline at end of file diff --git a/src/main/java/univ/yesummit/domain/board/domain/BoardLike.java b/src/main/java/univ/yesummit/domain/board/domain/BoardLike.java new file mode 100644 index 0000000..563aa0f --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/BoardLike.java @@ -0,0 +1,34 @@ +package univ.yesummit.domain.board.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import univ.yesummit.domain.member.entity.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BoardLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "board_like_id") + private Long boardLikeId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Builder + private BoardLike(Board board, Member member) { + this.board = board; + this.member = member; + } + +} \ No newline at end of file diff --git a/src/main/java/univ/yesummit/domain/board/domain/BoardPicture.java b/src/main/java/univ/yesummit/domain/board/domain/BoardPicture.java new file mode 100644 index 0000000..0a6b25e --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/BoardPicture.java @@ -0,0 +1,36 @@ +package univ.yesummit.domain.board.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BoardPicture { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "board_picture_id") + @Schema(description = "게시글의 사진 id", example = "1") + private Long boardPictureId; + + @Schema(description = "게시글 이미지 URL", example = "https://~~") + @NotNull(message = "게시글 이미지 삽입은 필수입니다.") + private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + + @Builder + private BoardPicture(String imageUrl, Board board) { + this.imageUrl = imageUrl; + this.board = board; + } + +} \ No newline at end of file diff --git a/src/main/java/univ/yesummit/domain/board/domain/Investment.java b/src/main/java/univ/yesummit/domain/board/domain/Investment.java new file mode 100644 index 0000000..5135f6c --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/Investment.java @@ -0,0 +1,34 @@ +package univ.yesummit.domain.board.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import univ.yesummit.domain.member.entity.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Investment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "investment_id") + private Long investmentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Builder + private Investment(Board board, Member member) { + this.board = board; + this.member = member; + } + +} diff --git a/src/main/java/univ/yesummit/domain/board/domain/repository/BoardLikeRepository.java b/src/main/java/univ/yesummit/domain/board/domain/repository/BoardLikeRepository.java new file mode 100644 index 0000000..913a35a --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/repository/BoardLikeRepository.java @@ -0,0 +1,19 @@ +package univ.yesummit.domain.board.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import univ.yesummit.domain.board.domain.Board; +import univ.yesummit.domain.board.domain.BoardLike; +import univ.yesummit.domain.member.entity.Member; + +import java.util.List; +import java.util.Optional; + +public interface BoardLikeRepository extends JpaRepository { + + boolean existsByBoardAndMember(Board board, Member member); + + Optional findByBoardAndMember(Board board, Member member); + + List findByMember(Member member); + +} diff --git a/src/main/java/univ/yesummit/domain/board/domain/repository/BoardPictureRepository.java b/src/main/java/univ/yesummit/domain/board/domain/repository/BoardPictureRepository.java new file mode 100644 index 0000000..5e3ef67 --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/repository/BoardPictureRepository.java @@ -0,0 +1,14 @@ +package univ.yesummit.domain.board.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import univ.yesummit.domain.board.domain.Board; +import univ.yesummit.domain.board.domain.BoardPicture; + +import java.util.List; + +public interface BoardPictureRepository extends JpaRepository { + + void deleteByBoardBoardId(Long boardId); + + void deleteByBoardAndImageUrlIn(Board board, List urlsToDelete); +} diff --git a/src/main/java/univ/yesummit/domain/board/domain/repository/BoardRepository.java b/src/main/java/univ/yesummit/domain/board/domain/repository/BoardRepository.java new file mode 100644 index 0000000..cfa1b16 --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/repository/BoardRepository.java @@ -0,0 +1,10 @@ +package univ.yesummit.domain.board.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import univ.yesummit.domain.board.domain.Board; + + +public interface BoardRepository extends JpaRepository { + + +} diff --git a/src/main/java/univ/yesummit/domain/board/domain/repository/InvestmentRepository.java b/src/main/java/univ/yesummit/domain/board/domain/repository/InvestmentRepository.java new file mode 100644 index 0000000..bf159b5 --- /dev/null +++ b/src/main/java/univ/yesummit/domain/board/domain/repository/InvestmentRepository.java @@ -0,0 +1,17 @@ +package univ.yesummit.domain.board.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import univ.yesummit.domain.board.domain.Board; +import univ.yesummit.domain.board.domain.Investment; +import univ.yesummit.domain.member.entity.Member; + +import java.util.Optional; + + +public interface InvestmentRepository extends JpaRepository { + + boolean existsByBoardAndMember(Board board, Member member); + + Optional findByBoardAndMember(Board board, Member member); + +} diff --git a/src/main/java/univ/yesummit/domain/comment/api/CommentController.java b/src/main/java/univ/yesummit/domain/comment/api/CommentController.java new file mode 100644 index 0000000..70588de --- /dev/null +++ b/src/main/java/univ/yesummit/domain/comment/api/CommentController.java @@ -0,0 +1,61 @@ +package univ.yesummit.domain.comment.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import univ.yesummit.domain.comment.api.dto.request.CommentSaveReqDto; +import univ.yesummit.domain.comment.api.dto.request.CommentUpdateReqDto; +import univ.yesummit.domain.comment.application.CommentService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/api/comment") +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 등록", description = "댓글을 등록합니다.") + @ApiResponse(responseCode = "200", description = "댓글 등록 성공") + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(example = "INVALID_HEADER or INVALID_TOKEN"))) + @PostMapping("/") + public ResponseEntity commentSave(@AuthenticationPrincipal String email, + @RequestBody CommentSaveReqDto commentSaveReqDto) { + + commentService.commentSave(email, commentSaveReqDto); + + String responseMessage = String.format("%d 게시글에 댓글이 등록되었습니다.", commentSaveReqDto.boardId()); + return ResponseEntity.status(HttpStatus.CREATED).body(responseMessage); + } + + @Operation(summary = "댓글 수정", description = "댓글을 수정합니다.") + @ApiResponse(responseCode = "200", description = "댓글 수정 성공") + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(example = "INVALID_HEADER or INVALID_TOKEN"))) + @PutMapping("/{commentId}") + public ResponseEntity commentUpdate(@AuthenticationPrincipal String email, + @PathVariable("commentId") Long commentId, + @RequestBody CommentUpdateReqDto commentUpdateReqDto) { + + commentService.commentUpdate(email, commentId, commentUpdateReqDto); + + String responseMessage = String.format("%d 게시글의 댓글이 수정돠었습니다.", commentId); + return ResponseEntity.status(HttpStatus.OK).body(responseMessage); + } + + @Operation(summary = "댓글 삭제", description = "댓글을 삭제합니다.") + @ApiResponse(responseCode = "200", description = "댓글 삭제 성공") + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(example = "INVALID_HEADER or INVALID_TOKEN"))) + @DeleteMapping("/{commentId}") + public ResponseEntity commentDelete(@AuthenticationPrincipal String email, + @PathVariable("commentId") Long commentId) { + commentService.commentDelete(email, commentId); + + String responseMessage = String.format("%d 게시글의 댓글이 삭제되었습니다.", commentId); + return ResponseEntity.status(HttpStatus.OK).body(responseMessage); + } +} diff --git a/src/main/java/univ/yesummit/domain/comment/api/dto/request/CommentSaveReqDto.java b/src/main/java/univ/yesummit/domain/comment/api/dto/request/CommentSaveReqDto.java new file mode 100644 index 0000000..7112b0e --- /dev/null +++ b/src/main/java/univ/yesummit/domain/comment/api/dto/request/CommentSaveReqDto.java @@ -0,0 +1,18 @@ +package univ.yesummit.domain.comment.api.dto.request; + +import univ.yesummit.domain.board.domain.Board; +import univ.yesummit.domain.comment.domain.Comment; +import univ.yesummit.domain.member.entity.Member; + +public record CommentSaveReqDto( + Long boardId, + String comment +) { + public Comment toEntity(Member member, Board board) { + return Comment.builder() + .comment(comment) + .writer(member) + .board(board) + .build(); + } +} diff --git a/src/main/java/univ/yesummit/domain/comment/api/dto/request/CommentUpdateReqDto.java b/src/main/java/univ/yesummit/domain/comment/api/dto/request/CommentUpdateReqDto.java new file mode 100644 index 0000000..761537b --- /dev/null +++ b/src/main/java/univ/yesummit/domain/comment/api/dto/request/CommentUpdateReqDto.java @@ -0,0 +1,6 @@ +package univ.yesummit.domain.comment.api.dto.request; + +public record CommentUpdateReqDto( + String comment +) { +} diff --git a/src/main/java/univ/yesummit/domain/comment/api/dto/response/CommentInfoResDto.java b/src/main/java/univ/yesummit/domain/comment/api/dto/response/CommentInfoResDto.java new file mode 100644 index 0000000..ee9512d --- /dev/null +++ b/src/main/java/univ/yesummit/domain/comment/api/dto/response/CommentInfoResDto.java @@ -0,0 +1,19 @@ +package univ.yesummit.domain.comment.api.dto.response; + +import lombok.Builder; +import univ.yesummit.domain.comment.domain.Comment; + +@Builder +public record CommentInfoResDto( + Long writerMemberId, + Long commentId, + String comment +) { + public static CommentInfoResDto of(Comment comment) { + return CommentInfoResDto.builder() + .writerMemberId(comment.getWriter().getId()) + .commentId(comment.getCommentId()) + .comment(comment.getComment()) + .build(); + } +} diff --git a/src/main/java/univ/yesummit/domain/comment/application/CommentService.java b/src/main/java/univ/yesummit/domain/comment/application/CommentService.java new file mode 100644 index 0000000..a9224f2 --- /dev/null +++ b/src/main/java/univ/yesummit/domain/comment/application/CommentService.java @@ -0,0 +1,62 @@ +package univ.yesummit.domain.comment.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import univ.yesummit.domain.board.domain.Board; +import univ.yesummit.domain.board.domain.repository.BoardRepository; +import univ.yesummit.domain.comment.api.dto.request.CommentSaveReqDto; +import univ.yesummit.domain.comment.api.dto.request.CommentUpdateReqDto; +import univ.yesummit.domain.comment.api.dto.response.CommentInfoResDto; +import univ.yesummit.domain.comment.domain.Comment; +import univ.yesummit.domain.comment.domain.repository.CommentRepository; +import univ.yesummit.domain.member.entity.Member; +import univ.yesummit.domain.member.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final MemberRepository memberRepository; + private final BoardRepository boardRepository; + + // 댓글 저장 + @Transactional + public void commentSave(String email, CommentSaveReqDto commentSaveReqDto) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다.")); + + Board board = boardRepository.findById(commentSaveReqDto.boardId()) + .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다.")); + + commentRepository.save(commentSaveReqDto.toEntity(member, board)); + } + + // 댓글 수정 + @Transactional + public CommentInfoResDto commentUpdate(String email, Long commentId, CommentUpdateReqDto commentUpdateReqDto){ + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다.")); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); + + comment.updateComment(commentUpdateReqDto.comment()); + + return CommentInfoResDto.of(comment); + } + + // 댓글 삭제 + @Transactional + public void commentDelete(String email, Long commentId) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다.")); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); + + commentRepository.delete(comment); + } +} diff --git a/src/main/java/univ/yesummit/domain/comment/domain/Comment.java b/src/main/java/univ/yesummit/domain/comment/domain/Comment.java new file mode 100644 index 0000000..7c94b6f --- /dev/null +++ b/src/main/java/univ/yesummit/domain/comment/domain/Comment.java @@ -0,0 +1,55 @@ +package univ.yesummit.domain.comment.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import univ.yesummit.domain.board.domain.Board; +import univ.yesummit.domain.member.entity.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long commentId; + + @Column(columnDefinition = "TEXT") + @Schema(description = "댓글", example = "댓글") + private String comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member writer; + + @Builder + private Comment(String comment, Member writer, Board board) { + this.comment = comment; + this.writer = writer; + this.board = board; + } + + public void updateComment(String comment) { + + if (comment == null || comment.trim().isEmpty()) { + throw new IllegalArgumentException("댓글 내용은 비어 있을 수 없습니다."); + } + if (this.comment.equals(comment)) { + // 새로운 내용이 현재 내용과 동일한 경우, 아무 작업도 하지 않음 + return; + } + this.comment = comment; + + System.out.println("댓글이 수정되었습니다: " + comment); + } + +} diff --git a/src/main/java/univ/yesummit/domain/comment/domain/repository/CommentRepository.java b/src/main/java/univ/yesummit/domain/comment/domain/repository/CommentRepository.java new file mode 100644 index 0000000..e7be2d3 --- /dev/null +++ b/src/main/java/univ/yesummit/domain/comment/domain/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package univ.yesummit.domain.comment.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import univ.yesummit.domain.comment.domain.Comment; + +public interface CommentRepository extends JpaRepository { +} diff --git a/src/main/java/univ/yesummit/domain/member/controller/MemberController.java b/src/main/java/univ/yesummit/domain/member/controller/MemberController.java index 973e3a3..8996681 100644 --- a/src/main/java/univ/yesummit/domain/member/controller/MemberController.java +++ b/src/main/java/univ/yesummit/domain/member/controller/MemberController.java @@ -1,6 +1,8 @@ package univ.yesummit.domain.member.controller; import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,6 +15,7 @@ import univ.yesummit.domain.member.dto.MemberUpdateDTO; import univ.yesummit.domain.member.dto.MemberWithdrawDTO; import univ.yesummit.domain.member.service.MemberService; +import univ.yesummit.global.auth.util.JwtUtils; import univ.yesummit.global.oauth.OAuth2Member; import univ.yesummit.global.resolver.LoginUser; import univ.yesummit.global.resolver.User; @@ -24,11 +27,13 @@ public class MemberController { private final MemberService memberService; + private final JwtUtils jwtUtils; /** * 첫 소셜 로그인 시에 추가적인 정보 수집(회원가입)진행 */ @PostMapping("/saveAdditionalInfo") + @Operation(summary = "추가 정보 수집", description = "첫 로그인 시에 추가적인 정보를 수집합니다.") public void saveAdditionalInfo(@User LoginUser loginUser, @Valid @RequestBody MemberSignUpDTO memberSignUpDTO) throws Exception { Long memberId = loginUser.getMemberId(); memberService.saveAdditionalInfo(memberId, memberSignUpDTO); @@ -68,5 +73,30 @@ public ResponseEntity getMyInfo(@User LoginUser loginUser) throws MemberInfoDTO myInfo = memberService.getMyInfo(memberId); return ResponseEntity.ok(myInfo); } + + /** + * 로그아웃 + */ + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "로그아웃을 진행합니다.") + public void logout(@User LoginUser loginUser) throws Exception { + Long memberId = loginUser.getMemberId(); + memberService.logout(memberId); + } + + /** + * 토큰 갱신 + */ + @PostMapping("/refresh") + @Operation(summary = "Access 토큰 재발급", description = "Refresh 토큰을 사용하여 새로운 Access 토큰을 발급받습니다.") + public ResponseEntity refreshAccessToken(HttpServletRequest request, HttpServletResponse response) throws Exception { + String refreshToken = jwtUtils.extractRefreshToken(request) + .orElseThrow(() -> new IllegalArgumentException("Refresh Token이 없습니다.")); + // 서비스 레이어에 토큰 재발급 요청 + String newAccessToken = memberService.refreshAccessToken(refreshToken); + // 새로운 Access 토큰을 응답 헤더에 추가 + jwtUtils.sendAccessToken(response, newAccessToken); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/univ/yesummit/domain/member/entity/Member.java b/src/main/java/univ/yesummit/domain/member/entity/Member.java index 00b9a41..cdaf102 100644 --- a/src/main/java/univ/yesummit/domain/member/entity/Member.java +++ b/src/main/java/univ/yesummit/domain/member/entity/Member.java @@ -27,6 +27,8 @@ public class Member { private String providerId; + private String refreshToken; + /** * 추가 정보 필드 */ @@ -93,4 +95,12 @@ public void updateAdditionalInfo( this.consentSummitAlerts = consentSummitAlerts; this.consentPrivacyPolicy = consentPrivacyPolicy; } + + //== 토큰 관련 ==// + public void updateRefreshToken(String newRefreshToken) { + this.refreshToken = newRefreshToken; + } + public void destroyRefreshToken() { + this.refreshToken = null; + } } diff --git a/src/main/java/univ/yesummit/domain/member/service/MemberService.java b/src/main/java/univ/yesummit/domain/member/service/MemberService.java index eeb40d9..046dbcf 100644 --- a/src/main/java/univ/yesummit/domain/member/service/MemberService.java +++ b/src/main/java/univ/yesummit/domain/member/service/MemberService.java @@ -33,4 +33,19 @@ public interface MemberService { */ MemberInfoDTO getMyInfo(Long memberId) throws Exception; + /** + * 로그아웃 + */ + void logout(Long memberId) throws Exception; + + /** + * 토큰 업데이트 + */ + void updateRefreshToken(Long memberId, String refreshToken) throws Exception; + /** + * 토큰 재발급 + */ + String refreshAccessToken(String refreshToken) throws Exception; + + } diff --git a/src/main/java/univ/yesummit/domain/member/service/impl/MemberServiceImpl.java b/src/main/java/univ/yesummit/domain/member/service/impl/MemberServiceImpl.java index bd4a109..8d2a137 100644 --- a/src/main/java/univ/yesummit/domain/member/service/impl/MemberServiceImpl.java +++ b/src/main/java/univ/yesummit/domain/member/service/impl/MemberServiceImpl.java @@ -11,6 +11,7 @@ import univ.yesummit.domain.member.exception.MemberException; import univ.yesummit.domain.member.repository.MemberRepository; import univ.yesummit.domain.member.service.MemberService; +import univ.yesummit.global.auth.util.JwtUtils; import univ.yesummit.global.auth.util.SecurityUtil; import univ.yesummit.global.exception.ErrorCode; @@ -20,6 +21,7 @@ public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; + private final JwtUtils jwtUtils; @Override public void saveAdditionalInfo(Long memberId, MemberSignUpDTO memberSignUpDTO) throws Exception { @@ -83,4 +85,34 @@ public MemberInfoDTO getMyInfo(Long memberId) throws Exception { .orElseThrow(() -> new MemberException(ErrorCode.NOT_FOUND_MEMBER)); return new MemberInfoDTO(member); } + + @Override + public void logout(Long memberId) throws Exception { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(ErrorCode.NOT_FOUND_MEMBER)); + member.destroyRefreshToken(); + memberRepository.save(member); + } + + @Override + public String refreshAccessToken(String refreshToken) throws Exception { + if (!jwtUtils.isValid(refreshToken)) { + throw new IllegalArgumentException("유효하지 않은 Refresh Token 토큰입니다"); + } + Long memberId = jwtUtils.extractMemberId(refreshToken) + .orElseThrow(() -> new MemberException(ErrorCode.NOT_FOUND_MEMBER)); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(ErrorCode.NOT_FOUND_MEMBER)); + if(!refreshToken.equals(member.getRefreshToken())) { + throw new IllegalArgumentException("Refresh Token 정보가 일치하지 않습니다."); + } + return jwtUtils.createAccessToken(memberId); + } + @Override + public void updateRefreshToken(Long memberId, String refreshToken) throws Exception { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(ErrorCode.NOT_FOUND_MEMBER)); + member.updateRefreshToken(refreshToken); + memberRepository.save(member); + } } diff --git a/src/main/java/univ/yesummit/global/auth/util/JwtUtils.java b/src/main/java/univ/yesummit/global/auth/util/JwtUtils.java index 36af6fa..cccc0f4 100644 --- a/src/main/java/univ/yesummit/global/auth/util/JwtUtils.java +++ b/src/main/java/univ/yesummit/global/auth/util/JwtUtils.java @@ -2,12 +2,8 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.interfaces.DecodedJWT; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.transaction.Transactional; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,8 +12,6 @@ import univ.yesummit.domain.member.repository.MemberRepository; import java.util.Date; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; @Getter @@ -65,10 +59,11 @@ public String createAccessToken(Long memberId) { } - public String createRefreshToken() { + public String createRefreshToken(Long memberId) { return JWT.create() .withSubject(REFRESH_TOKEN_SUBJECT) .withExpiresAt(new Date(System.currentTimeMillis() + refreshExpiration)) + .withClaim(ID_CLAIM, memberId) .sign(Algorithm.HMAC512(secret)); } diff --git a/src/main/java/univ/yesummit/global/oauth/OAuth2SuccessHandler.java b/src/main/java/univ/yesummit/global/oauth/OAuth2SuccessHandler.java index d411074..a2778cf 100644 --- a/src/main/java/univ/yesummit/global/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/univ/yesummit/global/oauth/OAuth2SuccessHandler.java @@ -1,6 +1,7 @@ package univ.yesummit.global.oauth; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -28,20 +29,41 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo OAuth2Member oAuth2Member = (OAuth2Member) authentication.getPrincipal(); Long memberId = oAuth2Member.getMemberId(); - // JWT 토큰 발급 + // JWT 토큰 생성 String accessToken = jwtUtils.createAccessToken(memberId); - String refreshToken = jwtUtils.createRefreshToken(); - jwtUtils.sendAccessAndRefreshToken(response, accessToken, refreshToken); + String refreshToken = jwtUtils.createRefreshToken(memberId); - // 기존 회원인지 확인 - // 기존 회원인지 확인 + // Refresh 토큰을 멤버 엔티티에 저장 + try { + memberService.updateRefreshToken(memberId, refreshToken); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // 토큰을 쿠키에 저장 + int accessTokenMaxAge = jwtUtils.getAccessExpiration().intValue() / 1000; // 밀리초를 초로 변환 + int refreshTokenMaxAge = jwtUtils.getRefreshExpiration().intValue() / 1000; + + Cookie accessTokenCookie = new Cookie("accessToken", accessToken); + accessTokenCookie.setHttpOnly(true); + accessTokenCookie.setSecure(true); + accessTokenCookie.setPath("/"); + accessTokenCookie.setMaxAge(accessTokenMaxAge); + + Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge(refreshTokenMaxAge); + + response.addCookie(accessTokenCookie); + response.addCookie(refreshTokenCookie); + + // 첫 로그인 여부에 따라 리다이렉트 if (memberService.isFirstLogin(memberId)) { - // 첫 로그인 시 추가 정보 입력 페이지로 리다이렉트 response.sendRedirect("/additional-info"); - return; + } else { + response.sendRedirect("/home"); } - - // 기존 회원이라면 정상 응답 (e.g., 메인 페이지로 리다이렉트) - response.sendRedirect("/home"); } }