Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public enum ErrorStatus implements BaseErrorCode {
_MEMBER_UNSUPPORTED_LOGIN_TYPE(400, "MEMBER4003", "지원하지 않는 로그인 타입입니다."),
_MEMBER_EMAIL_EXIST(400, "MEMBER4004", "이미 해당 방식으로 가입 내역이 존재하는 이메일입니다."),
_FAIL_TO_UPDATE_NAME(400, "MEMBER4005", "이름 변경에 실패했습니다."),
_CANNOT_WITHDRAW_WITH_ACTIVE_STUDY(400, "MEMBER4006", "운영 중인 스터디가 있어 탈퇴할 수 없습니다."),

// 스터디 관련
_MAX_MEMBERS_MUST_BE_POSITIVE(400, "STUDY4000", "최대 인원 수는 양수여야 합니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.spot.application.event;

import kr.spot.domain.events.MemberWithdrawnEvent;
import kr.spot.infrastructure.jpa.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class RevokeTokenOnMemberWithdrawn {

private final RefreshTokenRepository refreshTokenRepository;

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(MemberWithdrawnEvent event) {
refreshTokenRepository.deleteByMemberId(event.memberId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package kr.spot.application.token;

import kr.spot.infrastructure.jpa.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class LogoutService {

private final RefreshTokenRepository refreshTokenRepository;

@Transactional
public void logout(long memberId) {
refreshTokenRepository.deleteByMemberId(memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.spot.ApiResponse;
import kr.spot.annotations.CurrentMember;
import kr.spot.application.token.LogoutService;
import kr.spot.application.token.TokenReissueService;
import kr.spot.code.status.SuccessStatus;
import kr.spot.presentation.command.dto.TokenDTO;
Expand All @@ -23,6 +25,7 @@
public class AuthController {

private final TokenReissueService tokenReissueService;
private final LogoutService logoutService;

@Operation(summary = "토큰 재발급", description = """
## 리프레시 토큰을 통해 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다.
Expand All @@ -45,4 +48,20 @@ public ResponseEntity<ApiResponse<TokenDTO>> reissueToken(
return ResponseEntity.ok(
ApiResponse.onSuccess(SuccessStatus._OK, tokenReissueService.reissueToken(refreshToken)));
}

@Operation(summary = "로그아웃", description = """
## 로그아웃을 수행합니다.
- 서버에 저장된 리프레시 토큰을 삭제합니다.
- 액세스 토큰은 만료될 때까지 유효하므로, 클라이언트에서도 토큰을 삭제해주세요.
""")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
})
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
@CurrentMember long memberId
) {
logoutService.logout(memberId);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kr.spot.application.event;

import static kr.spot.common.AuthFixture.MEMBER_ID;
import static org.mockito.Mockito.verify;

import kr.spot.domain.events.MemberWithdrawnEvent;
import kr.spot.infrastructure.jpa.RefreshTokenRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class RevokeTokenOnMemberWithdrawnTest {

@Mock
RefreshTokenRepository refreshTokenRepository;

RevokeTokenOnMemberWithdrawn handler;

@BeforeEach
void setUp() {
handler = new RevokeTokenOnMemberWithdrawn(refreshTokenRepository);
}

@Test
@DisplayName("회원 탈퇴 시 해당 회원의 리프레시 토큰을 삭제한다")
void should_deleteRefreshToken_when_memberWithdrawn() {
// given
MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID);

// when
handler.handle(event);

// then
verify(refreshTokenRepository).deleteByMemberId(MEMBER_ID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package kr.spot.application.token;

import static kr.spot.common.AuthFixture.MEMBER_ID;
import static org.mockito.Mockito.verify;

import kr.spot.infrastructure.jpa.RefreshTokenRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class LogoutServiceTest {

@Mock
RefreshTokenRepository refreshTokenRepository;

LogoutService service;

@BeforeEach
void setUp() {
service = new LogoutService(refreshTokenRepository);
}

@Test
@DisplayName("로그아웃 시 해당 회원의 리프레시 토큰을 삭제한다")
void should_deleteRefreshToken_when_logout() {
// when
service.logout(MEMBER_ID);

// then
verify(refreshTokenRepository).deleteByMemberId(MEMBER_ID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package kr.spot.application.command;

import kr.spot.code.status.ErrorStatus;
import kr.spot.domain.events.MemberWithdrawnEvent;
import kr.spot.exception.GeneralException;
import kr.spot.infrastructure.jpa.MemberRepository;
import kr.spot.ports.HasActiveStudyAsLeaderPort;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class MemberWithdrawService {

private final MemberRepository memberRepository;
private final ApplicationEventPublisher eventPublisher;
private final HasActiveStudyAsLeaderPort hasActiveStudyAsLeaderPort;

@Transactional
public void withdraw(long memberId) {
validateCanWithdraw(memberId);
memberRepository.deleteById(memberId);
eventPublisher.publishEvent(new MemberWithdrawnEvent(memberId));
}

private void validateCanWithdraw(long memberId) {
if (hasActiveStudyAsLeaderPort.hasActiveStudyAsLeader(memberId)) {
throw new GeneralException(ErrorStatus._CANNOT_WITHDRAW_WITH_ACTIVE_STUDY);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.spot.ApiResponse;
import kr.spot.annotations.CurrentMember;
import kr.spot.application.command.MemberWithdrawService;
import kr.spot.application.command.RegisterMemberInfoService;
import kr.spot.application.command.RegisterPreferredCategoryService;
import kr.spot.application.command.RegisterPreferredRegionService;
Expand All @@ -17,6 +18,7 @@
import kr.spot.presentation.command.dto.request.UpdateMemberNameRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -31,6 +33,7 @@ public class MemberCommandController {
private final RegisterPreferredCategoryService registerPreferredCategoryService;
private final RegisterPreferredRegionService registerPreferredRegionService;
private final RegisterMemberInfoService registerMemberInfoService;
private final MemberWithdrawService memberWithdrawService;

@Operation(summary = "선호 카테고리 등록", description = "회원의 선호 카테고리를 등록합니다. 한 번 저장 후 다시 호출하면 덮어씁니다. (이전 데이터 삭제)")
@ApiResponses({
Expand Down Expand Up @@ -83,4 +86,21 @@ public ResponseEntity<ApiResponse<Void>> updateMemberName(
registerMemberInfoService.updateMemberName(memberId, request);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT));
}

@Operation(summary = "회원 탈퇴", description = """
## 회원 탈퇴를 수행합니다.
- 회원 정보 및 관련 데이터가 모두 삭제됩니다.
- 클라이언트에서도 저장된 토큰을 삭제해주세요.
""")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "탈퇴 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class)))
})
@DeleteMapping("/me")
public ResponseEntity<ApiResponse<Void>> withdraw(
@CurrentMember @Parameter(hidden = true) long memberId
) {
memberWithdrawService.withdraw(memberId);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.spot.application.event;

import kr.spot.domain.events.MemberWithdrawnEvent;
import kr.spot.infrastructure.jpa.NotificationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class CleanupNotificationOnMemberWithdrawn {

private final NotificationRepository notificationRepository;

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(MemberWithdrawnEvent event) {
notificationRepository.deleteByTargetTargetMemberId(event.memberId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

public interface NotificationRepository extends JpaRepository<Notification, Long> {

void deleteByTargetTargetMemberId(long targetMemberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package kr.spot.application.event;

import static org.mockito.Mockito.verify;

import kr.spot.domain.events.MemberWithdrawnEvent;
import kr.spot.infrastructure.jpa.NotificationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class CleanupNotificationOnMemberWithdrawnTest {

private static final long MEMBER_ID = 1L;

@Mock
NotificationRepository notificationRepository;

CleanupNotificationOnMemberWithdrawn handler;

@BeforeEach
void setUp() {
handler = new CleanupNotificationOnMemberWithdrawn(notificationRepository);
}

@Test
@DisplayName("회원 탈퇴 시 알림을 삭제한다")
void should_deleteNotifications_when_memberWithdrawn() {
// given
MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID);

// when
handler.handle(event);

// then
verify(notificationRepository).deleteByTargetTargetMemberId(MEMBER_ID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package kr.spot.application.event;

import kr.spot.domain.events.MemberWithdrawnEvent;
import kr.spot.infrastructure.jpa.CommentRepository;
import kr.spot.infrastructure.jpa.PostLikeRepository;
import kr.spot.infrastructure.jpa.PostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class CleanupPostOnMemberWithdrawn {

private final PostLikeRepository postLikeRepository;
private final CommentRepository commentRepository;
private final PostRepository postRepository;

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(MemberWithdrawnEvent event) {
long memberId = event.memberId();
postLikeRepository.deleteAllByMemberId(memberId);
commentRepository.deleteByWriterInfoWriterId(memberId);
postRepository.deleteByWriterInfoWriterId(memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ default Comment getById(Long id) {
return findById(id)
.orElseThrow(() -> new GeneralException(ErrorStatus._COMMENT_NOT_FOUND));
}

void deleteByWriterInfoWriterId(long writerId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ int savePostLike(@Param("id") long id,
""", nativeQuery = true)
int hardDelete(@Param("postId") Long postId,
@Param("memberId") Long memberId);

@Modifying
@Query(value = "DELETE FROM post_like WHERE member_id = :memberId", nativeQuery = true)
void deleteAllByMemberId(@Param("memberId") long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,6 @@ default void validateExists(long postId) {
throw new GeneralException(ErrorStatus._POST_NOT_FOUND);
}
}

void deleteByWriterInfoWriterId(long writerId);
}
Loading