From 6cfb02ba7a95083719ae09e820126a8bdecf8d74 Mon Sep 17 00:00:00 2001 From: Sangdon Park Date: Tue, 17 Sep 2024 22:43:05 +0900 Subject: [PATCH] feature: soft delete (#156) * feature: Add soft delete * feature: Social login disconnect * fix: Exclude Apple social login disconnect * fix: Change error code * refactor: Inline address deletion Co-authored-by: Chanhyeok Seo --------- Co-authored-by: Chanhyeok Seo --- .../repick/domain/product/entity/Payment.java | 12 +++- .../product/repository/PaymentRepository.java | 6 ++ .../product/scheduler/ProductScheduler.java | 30 +++++++-- .../domain/user/api/UserController.java | 21 +++++- .../repick/domain/user/entity/User.java | 10 ++- .../user/repository/UserRepository.java | 4 +- .../domain/user/scheduler/UserScheduler.java | 30 +++++++++ .../domain/user/service/UserService.java | 65 +++++++++++++++++-- .../repick/global/entity/BaseEntity.java | 2 +- .../global/oauth/GoogleUserService.java | 6 ++ .../repick/global/oauth/KakaoUserService.java | 11 ++++ .../repick/global/oauth/NaverUserService.java | 16 +++++ 12 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/example/repick/domain/user/scheduler/UserScheduler.java diff --git a/src/main/java/com/example/repick/domain/product/entity/Payment.java b/src/main/java/com/example/repick/domain/product/entity/Payment.java index a5853490..68c876a7 100644 --- a/src/main/java/com/example/repick/domain/product/entity/Payment.java +++ b/src/main/java/com/example/repick/domain/product/entity/Payment.java @@ -9,6 +9,7 @@ import lombok.NoArgsConstructor; import java.math.BigDecimal; +import java.time.LocalDateTime; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -36,8 +37,9 @@ public class Payment extends BaseEntity { @Embedded private Address address; + private LocalDateTime deletedAt; @Builder - public Payment(Long userId, PaymentStatus paymentStatus, String iamportUid, String merchantUid, BigDecimal amount, String userName, String phoneNumber, Address address) { + public Payment(Long userId, PaymentStatus paymentStatus, String iamportUid, String merchantUid, BigDecimal amount, String userName, String phoneNumber, Address address, LocalDateTime deletedAt) { this.userId = userId; this.paymentStatus = paymentStatus; this.iamportUid = iamportUid; @@ -46,6 +48,7 @@ public Payment(Long userId, PaymentStatus paymentStatus, String iamportUid, Stri this.userName = userName; this.phoneNumber = phoneNumber; this.address = address; + this.deletedAt = deletedAt; } public static Payment of(Long userId, String merchantUid, BigDecimal amount, String userName, String phoneNumber, Address address) { @@ -59,6 +62,13 @@ public static Payment of(Long userId, String merchantUid, BigDecimal amount, Str .address(address) .build(); } + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + public void deleteAddress() { + this.address = null; + } public void updatePaymentStatus(PaymentStatus paymentStatus) { this.paymentStatus = paymentStatus; diff --git a/src/main/java/com/example/repick/domain/product/repository/PaymentRepository.java b/src/main/java/com/example/repick/domain/product/repository/PaymentRepository.java index 5bc9f98d..2f3bbb2d 100644 --- a/src/main/java/com/example/repick/domain/product/repository/PaymentRepository.java +++ b/src/main/java/com/example/repick/domain/product/repository/PaymentRepository.java @@ -3,10 +3,16 @@ import com.example.repick.domain.product.entity.Payment; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface PaymentRepository extends JpaRepository { Optional findByIamportUid(String iamportUid); Optional findByMerchantUid(String merchantUid); + + List findAllByUserId(Long userId); + + List findAllByDeletedAtBefore(LocalDateTime dateTime); } diff --git a/src/main/java/com/example/repick/domain/product/scheduler/ProductScheduler.java b/src/main/java/com/example/repick/domain/product/scheduler/ProductScheduler.java index 06ae0bab..40eb4282 100644 --- a/src/main/java/com/example/repick/domain/product/scheduler/ProductScheduler.java +++ b/src/main/java/com/example/repick/domain/product/scheduler/ProductScheduler.java @@ -6,17 +6,18 @@ import com.example.repick.domain.clothingSales.entity.ClothingSalesStateType; import com.example.repick.domain.clothingSales.repository.ClothingSalesRepository; import com.example.repick.domain.clothingSales.repository.ClothingSalesStateRepository; -import com.example.repick.domain.product.entity.Product; -import com.example.repick.domain.product.entity.ProductOrder; -import com.example.repick.domain.product.entity.ProductOrderState; -import com.example.repick.domain.product.entity.ProductStateType; +import com.example.repick.domain.product.entity.*; +import com.example.repick.domain.product.repository.PaymentRepository; import com.example.repick.domain.product.repository.ProductOrderRepository; import com.example.repick.domain.product.repository.ProductRepository; import com.example.repick.domain.product.service.ProductOrderService; import com.example.repick.domain.product.service.ProductService; +import com.example.repick.domain.user.entity.User; +import com.example.repick.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDateTime; @@ -33,6 +34,8 @@ public class ProductScheduler { private final ClothingSalesRepository clothingSalesRepository; private final ClothingSalesStateRepository clothingSalesStateRepository; private final AdminService adminService; + private final PaymentRepository paymentRepository; + private final UserRepository userRepository; @Scheduled(cron = "0 0 0 * * *") public void updateProductDiscountRate() { @@ -93,4 +96,23 @@ public void updateDeliveryTrackerWebhook() { productOrders.forEach(po -> adminService.enableTracking(po.getTrackingNumber(), carrierId, callbackUrl)); } + + @Scheduled(cron = "0 0 0 * * *") + public void deleteAddressAfterThirtyDays() { + LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30); + paymentRepository.findAllByDeletedAtBefore(thirtyDaysAgo) + .forEach(payment -> { + //결제 내역에서 결제 정보만 30일 뒤에 삭제 + payment.deleteAddress(); + paymentRepository.save(payment); + }); + } + + // 5년 후 결제 정보 전체 삭제하는 스케줄러 + @Scheduled(cron = "0 0 0 * * *") + public void deletePaymentAfterFiveYears() { + LocalDateTime fiveYearsAgo = LocalDateTime.now().minusYears(5); + List paymentsToDelete = paymentRepository.findAllByDeletedAtBefore(fiveYearsAgo); + paymentRepository.deleteAll(paymentsToDelete); + } } diff --git a/src/main/java/com/example/repick/domain/user/api/UserController.java b/src/main/java/com/example/repick/domain/user/api/UserController.java index 1631ec40..d40abb67 100644 --- a/src/main/java/com/example/repick/domain/user/api/UserController.java +++ b/src/main/java/com/example/repick/domain/user/api/UserController.java @@ -14,13 +14,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.tuple.Pair; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.util.Optional; -import java.nio.charset.StandardCharsets; @Tag(name = "User", description = "유저 관련 API") @@ -216,6 +214,23 @@ public SuccessResponse deleteUser() { return SuccessResponse.success(userService.deleteUser()); } + @Operation(summary = "회원 탈퇴하기", + description = """ + 회원 탈퇴 절차를 진행하고, 소셜로그인 연결을 끊습니다. + + **거래 및 결제 기록과 관련된 정보(이름, 연락처 등)은 5년간 보관합니다.** + + **그 외의 정보는 30일 후 삭제합니다.** + + """) + @DeleteMapping("/withdraw") + public SuccessResponse withdraw(@RequestParam(required = false) Optional accessToken) { + userService.withdraw(accessToken); + return SuccessResponse.success(true); + } + + + @Operation(summary = "SMS 인증번호 요청하기", description = """ SMS 인증번호를 요청합니다. diff --git a/src/main/java/com/example/repick/domain/user/entity/User.java b/src/main/java/com/example/repick/domain/user/entity/User.java index 121e646a..c7f511a0 100644 --- a/src/main/java/com/example/repick/domain/user/entity/User.java +++ b/src/main/java/com/example/repick/domain/user/entity/User.java @@ -8,6 +8,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.joda.time.DateTime; import java.time.LocalDateTime; @@ -27,7 +28,7 @@ public class User extends BaseEntity { private String phoneNumber; private String profileImage; private String password; - + private LocalDateTime deletedAt; // 추가 정보 private String nickname; @Embedded @@ -56,7 +57,7 @@ public class User extends BaseEntity { private Role role; @Builder - public User(Long id, OAuthProvider oAuthProvider, String providerId, String email, String nickname, String phoneNumber, Address defaultAddress, Account defaultAccount, String topSize, String bottomSize, String profileImage, String password, Role role, Boolean pushAllow, String fcmToken, Gender gender ) { + public User(Long id, OAuthProvider oAuthProvider, String providerId, String email, String nickname, String phoneNumber, Address defaultAddress, Account defaultAccount, String topSize, String bottomSize, String profileImage, String password, Role role, Boolean pushAllow, String fcmToken, Gender gender,LocalDateTime deletedAt ) { this.id = id; this.oAuthProvider = oAuthProvider; this.providerId = providerId; @@ -75,6 +76,7 @@ public User(Long id, OAuthProvider oAuthProvider, String providerId, String emai this.fcmToken = fcmToken; this.userClass = UserClass.ROOKIE_COLLECTOR; this.gender = gender; + this.deletedAt = deletedAt; } @@ -88,6 +90,7 @@ public void update(PatchUserInfo patchUserInfo) { this.pushAllow = patchUserInfo.pushAllow() != null ? patchUserInfo.pushAllow() : this.pushAllow; this.fcmToken = patchUserInfo.fcmToken() != null ? patchUserInfo.fcmToken() : this.fcmToken; this.gender = patchUserInfo.gender() != null ? patchUserInfo.gender() : this.gender; + } public void updateProfile(String profile) { @@ -102,6 +105,9 @@ public void updateClass(UserClass userClass) { this.userClass = userClass; } + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } public void addSettlement(long settlement) { this.settlement += settlement; } diff --git a/src/main/java/com/example/repick/domain/user/repository/UserRepository.java b/src/main/java/com/example/repick/domain/user/repository/UserRepository.java index cb0cee50..b578c916 100644 --- a/src/main/java/com/example/repick/domain/user/repository/UserRepository.java +++ b/src/main/java/com/example/repick/domain/user/repository/UserRepository.java @@ -2,7 +2,7 @@ import com.example.repick.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; - +import java.util.List; import java.time.LocalDateTime; import java.util.Optional; @@ -10,4 +10,6 @@ public interface UserRepository extends JpaRepository, UserRepositor Optional findByProviderId(String providerId); + List findAllByDeletedAtBefore(LocalDateTime thirtyDaysAgo); + } diff --git a/src/main/java/com/example/repick/domain/user/scheduler/UserScheduler.java b/src/main/java/com/example/repick/domain/user/scheduler/UserScheduler.java new file mode 100644 index 00000000..2df2bca0 --- /dev/null +++ b/src/main/java/com/example/repick/domain/user/scheduler/UserScheduler.java @@ -0,0 +1,30 @@ +package com.example.repick.domain.user.scheduler; + +import com.example.repick.domain.product.entity.Payment; +import com.example.repick.domain.product.repository.PaymentRepository; +import com.example.repick.domain.user.entity.User; +import com.example.repick.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class UserScheduler { + + private final UserRepository userRepository; + private final PaymentRepository paymentRepository; + + // 매일 자정에 실행되는 스케줄러 + @Scheduled(cron = "0 0 0 * * *") + public void deleteSoftDeletedUsers() { + // 30일이 지난 소프트 삭제된 사용자 조회 + LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30); + List usersToDelete = userRepository.findAllByDeletedAtBefore(thirtyDaysAgo); + + userRepository.deleteAll(usersToDelete); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/repick/domain/user/service/UserService.java b/src/main/java/com/example/repick/domain/user/service/UserService.java index 97fb109f..f8f60139 100644 --- a/src/main/java/com/example/repick/domain/user/service/UserService.java +++ b/src/main/java/com/example/repick/domain/user/service/UserService.java @@ -6,6 +6,8 @@ import com.example.repick.domain.product.repository.ProductOrderRepository; import com.example.repick.domain.user.dto.*; import com.example.repick.domain.user.entity.User; +import com.example.repick.domain.product.entity.Payment; +import com.example.repick.domain.product.repository.PaymentRepository; import com.example.repick.domain.user.entity.UserSmsVerificationInfo; import com.example.repick.domain.user.repository.UserRepository; import com.example.repick.dynamodb.UserFcmTokenInfoRepository; @@ -17,6 +19,10 @@ import com.example.repick.global.jwt.TokenResponse; import com.example.repick.global.jwt.TokenService; import com.example.repick.global.jwt.UserDetailsImpl; +import com.example.repick.global.oauth.AppleUserService; +import com.example.repick.global.oauth.GoogleUserService; +import com.example.repick.global.oauth.KakaoUserService; +import com.example.repick.global.oauth.NaverUserService; import com.example.repick.global.sms.MessageService; import com.example.repick.global.util.StringParser; import lombok.RequiredArgsConstructor; @@ -25,6 +31,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.util.Optional; import java.time.LocalDateTime; import java.time.ZoneId; @@ -44,7 +51,11 @@ public class UserService { private final TokenService tokenService; private final ProductOrderRepository productOrderRepository; private final UserPreferenceRepository userPreferenceRepository; - + private final PaymentRepository paymentRepository; + private final NaverUserService naverUserService; + private final KakaoUserService kakaoUserService; + private final AppleUserService appleUserService; + private final GoogleUserService googleUserService; public GetUserInfo getUserInfo() { User user = userRepository.findByProviderId(SecurityContextHolder.getContext().getAuthentication().getName()) @@ -56,7 +67,7 @@ public GetUserInfo getUserInfo() { public Boolean patchUserInfo(PatchUserInfo patchUserInfo) { User user = userRepository.findByProviderId(SecurityContextHolder.getContext().getAuthentication().getName()) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); user.update(patchUserInfo); @@ -72,7 +83,7 @@ public Boolean registerProfile(MultipartFile profileImage) { String email = SecurityContextHolder.getContext().getAuthentication().getName(); User user = userRepository.findByProviderId(email) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); String profile = s3UploadService.saveFile(profileImage, "profile/" + user.getId().toString()); @@ -89,7 +100,7 @@ public Boolean deleteUser() { String email = SecurityContextHolder.getContext().getAuthentication().getName(); User user = userRepository.findByProviderId(email) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); // delete fcm token from ddb userFcmTokenInfoRepository.deleteById(user.getId()); @@ -101,10 +112,52 @@ public Boolean deleteUser() { return true; } + @Transactional + public Boolean withdraw(Optional accessToken) { + String email = SecurityContextHolder.getContext().getAuthentication().getName(); + + User user = userRepository.findByProviderId(email) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 소셜 로그인 연결 해제 로직 + switch (user.getOAuthProvider()) { + case KAKAO: + accessToken.ifPresent(kakaoUserService::disconnectKakao); + break; + case NAVER: + accessToken.ifPresent(naverUserService::disconnectNaver); + break; + case GOOGLE: + accessToken.ifPresent(googleUserService::disconnectGoogle); + break; + case APPLE: + break; + default: + throw new CustomException(ErrorCode.INVALID_REQUEST_ERROR); + } + if (user.getDeletedAt() == null) { + user.setDeletedAt(LocalDateTime.now()); + user.setIsDeleted(); + userRepository.save(user); + } + + List payments = paymentRepository.findAllByUserId(user.getId()); + payments.forEach(payment -> { + if (payment.getDeletedAt() == null) { + payment.setDeletedAt(LocalDateTime.now()); + payment.setIsDeleted(); + paymentRepository.save(payment); + } + }); + + return true; + } + + @Transactional public Boolean initSmsVerification(PostInitSmsVerification postInitSmsVerification) { User user = userRepository.findByProviderId(SecurityContextHolder.getContext().getAuthentication().getName()) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); String phoneNumber = StringParser.parsePhoneNumber(postInitSmsVerification.phoneNumber()); String randomNumber = messageService.sendSMS(postInitSmsVerification.phoneNumber()); @@ -124,7 +177,7 @@ public Boolean initSmsVerification(PostInitSmsVerification postInitSmsVerificati @Transactional public Boolean verifySmsVerification(PostVerifySmsVerification postVerifySmsVerification) { User user = userRepository.findByProviderId(SecurityContextHolder.getContext().getAuthentication().getName()) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); String phoneNumber = StringParser.parsePhoneNumber(postVerifySmsVerification.phoneNumber()); diff --git a/src/main/java/com/example/repick/global/entity/BaseEntity.java b/src/main/java/com/example/repick/global/entity/BaseEntity.java index f7c073d8..f1e6495b 100644 --- a/src/main/java/com/example/repick/global/entity/BaseEntity.java +++ b/src/main/java/com/example/repick/global/entity/BaseEntity.java @@ -27,5 +27,5 @@ public class BaseEntity { public void delete() { this.isDeleted = true; } - + public void setIsDeleted() {this.isDeleted = true;} } diff --git a/src/main/java/com/example/repick/global/oauth/GoogleUserService.java b/src/main/java/com/example/repick/global/oauth/GoogleUserService.java index 9e1ec294..e871bac4 100644 --- a/src/main/java/com/example/repick/global/oauth/GoogleUserService.java +++ b/src/main/java/com/example/repick/global/oauth/GoogleUserService.java @@ -114,6 +114,12 @@ private Pair registerGoogleUserIfNeed(GoogleUserDto googleUserInf return Pair.of(googleUser, false); } + public void disconnectGoogle(String accessToken){ + String url = "https://oauth2.googleapis.com/revoke?token=" + accessToken; + RestTemplate restTemplate = new RestTemplate(); + restTemplate.postForEntity(url, null, String.class); + } + private GoogleUserDto handleGoogleResponse(String responseBody) throws JsonProcessingException { ObjectMapper objectMapper = new ObjectMapper(); diff --git a/src/main/java/com/example/repick/global/oauth/KakaoUserService.java b/src/main/java/com/example/repick/global/oauth/KakaoUserService.java index 5ea08139..10a4dc29 100644 --- a/src/main/java/com/example/repick/global/oauth/KakaoUserService.java +++ b/src/main/java/com/example/repick/global/oauth/KakaoUserService.java @@ -50,6 +50,17 @@ public Pair kakaoLogin(String accessToken) throws JsonPr } + public void disconnectKakao(String accessToken) { + String url = "https://kapi.kakao.com/v1/user/unlink"; + System.out.println("accessToken: " + accessToken); + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + accessToken); + + RestTemplate restTemplate = new RestTemplate(); + HttpEntity request = new HttpEntity<>(headers); + restTemplate.postForEntity(url, request, String.class); + } + private KakaoUserDto getKakaoUserInfo(String accessToken) throws JsonProcessingException { // HTTP Header 생성 HttpHeaders headers = new HttpHeaders(); diff --git a/src/main/java/com/example/repick/global/oauth/NaverUserService.java b/src/main/java/com/example/repick/global/oauth/NaverUserService.java index f2c1eebb..b8d3dfdb 100644 --- a/src/main/java/com/example/repick/global/oauth/NaverUserService.java +++ b/src/main/java/com/example/repick/global/oauth/NaverUserService.java @@ -58,6 +58,22 @@ private NaverUserDto getNaverUserInfo(String accessToken) throws JsonProcessingE return objectMapper.readValue(response.getBody(), NaverUserDto.class); } + public void disconnectNaver(String accessToken) { + String url = "https://nid.naver.com/oauth2.0/token?grant_type=delete&client_id=" + clientId + + "&client_secret=" + clientSecret + "&access_token=" + accessToken; + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + accessToken); + HttpEntity> request = new HttpEntity<>(headers); + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + request, + String.class + ); + } + + private Pair registerNaverUserIfNeed(NaverUserDto naverUserInfo) { String providerId = naverUserInfo.getId(); User naverUser = userRepository.findByProviderId(providerId).orElse(null);