diff --git a/src/main/java/com/back/domain/user/controller/UserAccountController.java b/src/main/java/com/back/domain/user/controller/UserAccountController.java new file mode 100644 index 00000000..c10be74c --- /dev/null +++ b/src/main/java/com/back/domain/user/controller/UserAccountController.java @@ -0,0 +1,37 @@ +package com.back.domain.user.controller; + +import com.back.domain.user.service.UserService; +import com.back.domain.user.service.UserAuthService; +import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/me/account") +@RequiredArgsConstructor +public class UserAccountController { + + private final UserService userService; + private final UserAuthService userAuthService; + + @DeleteMapping + @Operation(summary = "계정 비활성화(Soft Delete)", description = "DELETE /me/account: 사용자 상태를 DELETED로 전환하고 세션/토큰을 정리합니다.") + public RsData deactivate( + @AuthenticationPrincipal(expression = "id") Long userId, + HttpServletRequest request, + HttpServletResponse response + ) { + userService.deactivateAccount(userId); + + // 현재 세션 쿠키 및 리프레시토큰 제거 + userAuthService.logout(request, response); + + return RsData.of(200, "계정 비활성화(탈퇴)가 완료되었습니다."); + } +} diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index 7c52b90c..4ebc5e22 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -2,6 +2,7 @@ import com.back.domain.post.post.entity.PostLike; import jakarta.persistence.*; +import com.back.domain.user.enums.UserStatus; import lombok.*; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; @@ -56,6 +57,13 @@ public class User { @Column(nullable = false) private boolean isFirstLogin = true; + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserStatus status = UserStatus.ACTIVE; + + private LocalDateTime deletedAt; + // 양방향 매핑을 위한 필드 @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List postLikes = new ArrayList<>(); @@ -82,4 +90,17 @@ public Collection getAuthorities() { .map(auth -> new SimpleGrantedAuthority("ROLE_" + auth)) .toList(); } -} \ No newline at end of file + + public boolean isDeleted() { + return this.status == UserStatus.DELETED; + } + + public void markDeleted(String anonymizedNickname) { + this.status = UserStatus.DELETED; + this.deletedAt = LocalDateTime.now(); + this.nickname = anonymizedNickname; + // 민감정보 최소화: 재가입 허용을 위해 이메일/OAuth 식별자 제거 + this.email = null; + this.oauthId = null; + } +} diff --git a/src/main/java/com/back/domain/user/enums/UserStatus.java b/src/main/java/com/back/domain/user/enums/UserStatus.java new file mode 100644 index 00000000..453b9f53 --- /dev/null +++ b/src/main/java/com/back/domain/user/enums/UserStatus.java @@ -0,0 +1,6 @@ +package com.back.domain.user.enums; + +public enum UserStatus { + ACTIVE, + DELETED +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 91b3ecbf..c136ab6a 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -1,7 +1,10 @@ package com.back.domain.user.service; import com.back.domain.user.entity.User; +import com.back.domain.user.enums.UserStatus; import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.ServiceException; +import com.back.global.jwt.refreshToken.service.RefreshTokenService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +14,7 @@ public class UserService { private final UserRepository userRepository; + private final RefreshTokenService refreshTokenService; @Transactional(readOnly = true) public User findById(Long id) { @@ -18,7 +22,19 @@ public User findById(Long id) { .orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id)); } + @Transactional + public void deactivateAccount(Long id) { + User user = userRepository.findById(id) + .orElseThrow(() -> new ServiceException(404, "사용자를 찾을 수 없습니다.")); + if (user.getStatus() == UserStatus.DELETED) { + throw new ServiceException(409, "이미 탈퇴한 사용자입니다."); + } + user.markDeleted("탈퇴한 사용자"); + userRepository.save(user); -} \ No newline at end of file + // 모든 세션(리프레시 토큰) 폐기 + refreshTokenService.revokeAllForUser(id); + } +} diff --git a/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java b/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java index c7972977..2ae55f21 100644 --- a/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java +++ b/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java @@ -74,6 +74,12 @@ public void revokeToken(String token) { refreshTokenRepository.deleteByToken(token); } + // 사용자 전체 세션(리프레시 토큰) 폐기 + @Transactional + public void revokeAllForUser(Long userId) { + refreshTokenRepository.deleteByUserId(userId); + } + //문자열 난수 조합 private String generateSecureToken() { byte[] randomBytes = new byte[32];