diff --git a/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentDAO.java b/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentDAO.java index 0f56bff4..e77f4a88 100644 --- a/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentDAO.java +++ b/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentDAO.java @@ -8,4 +8,6 @@ public interface CommentDAO extends JpaRepository { @Query("select c from Comment c join fetch c.member left join fetch c.parentComment where c.diary.id = :diaryId order by c.parentComment.id asc nulls first, c.id asc") List findAllByDiaryId(Long diaryId, Pageable pageable); + + List findAllByMemberId(Long memberId); } diff --git a/src/main/java/chzzk/grassdiary/domain/member/controller/MemberController.java b/src/main/java/chzzk/grassdiary/domain/member/controller/MemberController.java index 5e5b7fc9..88121727 100644 --- a/src/main/java/chzzk/grassdiary/domain/member/controller/MemberController.java +++ b/src/main/java/chzzk/grassdiary/domain/member/controller/MemberController.java @@ -6,6 +6,7 @@ import chzzk.grassdiary.domain.member.service.MyPageService; import chzzk.grassdiary.domain.member.dto.MemberInfoDTO; import chzzk.grassdiary.domain.member.dto.TotalRewardDTO; +import chzzk.grassdiary.domain.member.service.WithdrawnMemberService; import chzzk.grassdiary.domain.reward.service.RewardService; import chzzk.grassdiary.global.auth.common.AuthenticatedMember; import chzzk.grassdiary.global.auth.service.dto.AuthMemberPayload; @@ -17,6 +18,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -30,6 +32,7 @@ public class MemberController { private final MyPageService myPageService; private final RewardService rewardService; + private final WithdrawnMemberService withdrawnMemberService; private final MemberService memberService; @GetMapping("profile/{memberId}") @@ -52,6 +55,12 @@ public ResponseEntity getTotalReward(@PathVariable Long memberId) { return ResponseEntity.ok(rewardService.findTotalRewardById(memberId)); } + @DeleteMapping("/withdraw") + public ResponseEntity withdrawMember(@AuthenticatedMember AuthMemberPayload payload) { + withdrawnMemberService.withdrawMember(payload.id()); + return ResponseEntity.ok("탈퇴가 완료되었습니다."); + } + @GetMapping("/{memberId}/colors") public MemberPurchasedColorsResponseDTO getMemberColors(@PathVariable Long memberId) { return memberService.getPurchasedColors(memberId); diff --git a/src/main/java/chzzk/grassdiary/domain/member/entity/Member.java b/src/main/java/chzzk/grassdiary/domain/member/entity/Member.java index bfd4a538..4c6c664c 100644 --- a/src/main/java/chzzk/grassdiary/domain/member/entity/Member.java +++ b/src/main/java/chzzk/grassdiary/domain/member/entity/Member.java @@ -96,6 +96,14 @@ public void addRandomPoint(Integer randomPoint) { this.rewardPoint += randomPoint; } + + public void withdrawMember() { + this.rewardPoint = 0; + this.nickname = "탈퇴한 회원"; + this.email = "withdrawnMember"; + this.profileIntro = null; + this.picture = null; + public void deductRewardPoints(int points) { if (this.rewardPoint < points) { throw new SystemException(ClientErrorCode.INSUFFICIENT_REWARD_POINTS_ERR); diff --git a/src/main/java/chzzk/grassdiary/domain/member/entity/WithdrawnMember.java b/src/main/java/chzzk/grassdiary/domain/member/entity/WithdrawnMember.java new file mode 100644 index 00000000..d1493ca1 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/member/entity/WithdrawnMember.java @@ -0,0 +1,30 @@ +package chzzk.grassdiary.domain.member.entity; + +import chzzk.grassdiary.domain.base.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WithdrawnMember extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "withdrawn_member_id") + private Long id; + + @Column(name = "hashed_email", nullable = false, unique = true) + private String hashedEmail; + + @Builder + public WithdrawnMember(String hashedEmail) { + this.hashedEmail = hashedEmail; + } +} diff --git a/src/main/java/chzzk/grassdiary/domain/member/entity/WithdrawnMemberDAO.java b/src/main/java/chzzk/grassdiary/domain/member/entity/WithdrawnMemberDAO.java new file mode 100644 index 00000000..ff87b743 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/member/entity/WithdrawnMemberDAO.java @@ -0,0 +1,7 @@ +package chzzk.grassdiary.domain.member.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WithdrawnMemberDAO extends JpaRepository { + boolean existsByHashedEmail(String hashedEmail); +} \ No newline at end of file diff --git a/src/main/java/chzzk/grassdiary/domain/member/service/MemberService.java b/src/main/java/chzzk/grassdiary/domain/member/service/MemberService.java index a0133135..2932e5b7 100644 --- a/src/main/java/chzzk/grassdiary/domain/member/service/MemberService.java +++ b/src/main/java/chzzk/grassdiary/domain/member/service/MemberService.java @@ -1,5 +1,7 @@ package chzzk.grassdiary.domain.member.service; +import chzzk.grassdiary.domain.diary.entity.Diary; +import chzzk.grassdiary.domain.diary.entity.DiaryDAO; import chzzk.grassdiary.domain.member.dto.EquipColorResponseDTO; import chzzk.grassdiary.domain.member.dto.MemberPurchasedColorResponseDTO; import chzzk.grassdiary.domain.member.dto.MemberPurchasedColorsResponseDTO; diff --git a/src/main/java/chzzk/grassdiary/domain/member/service/WithdrawnMemberService.java b/src/main/java/chzzk/grassdiary/domain/member/service/WithdrawnMemberService.java new file mode 100644 index 00000000..690fd8e2 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/member/service/WithdrawnMemberService.java @@ -0,0 +1,109 @@ +package chzzk.grassdiary.domain.member.service; + +import chzzk.grassdiary.domain.comment.entity.Comment; +import chzzk.grassdiary.domain.comment.entity.CommentDAO; +import chzzk.grassdiary.domain.comment.service.CommentService; +import chzzk.grassdiary.domain.diary.entity.Diary; +import chzzk.grassdiary.domain.diary.entity.DiaryDAO; +import chzzk.grassdiary.domain.diary.service.DiaryService; +import chzzk.grassdiary.domain.member.entity.Member; +import chzzk.grassdiary.domain.member.entity.MemberDAO; +import chzzk.grassdiary.domain.member.entity.WithdrawnMember; +import chzzk.grassdiary.domain.member.entity.WithdrawnMemberDAO; +import chzzk.grassdiary.domain.reward.service.RewardService; +import chzzk.grassdiary.global.common.error.exception.SystemException; +import chzzk.grassdiary.global.common.response.ClientErrorCode; +import chzzk.grassdiary.global.common.response.ServerErrorCode; +import chzzk.grassdiary.global.util.hash.EmailHasher; +import java.util.Base64; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class WithdrawnMemberService { + private final MemberDAO memberDAO; + private final DiaryDAO diaryDAO; + private final CommentDAO commentDAO; + private final WithdrawnMemberDAO withdrawnMemberDAO; + private final DiaryService diaryService; + private final CommentService commentService; + private final RewardService rewardService; + private final EmailHasher emailHasher; + + @Transactional + public void withdrawMember(Long logInMemberId) { + Member member = findMember(logInMemberId); + + deleteDiaries(logInMemberId); + + deleteComments(logInMemberId); + + deleteRewards(logInMemberId); + + //구입한 잔디 색깔 이력 삭제 + + //email 해싱 + String hashedEmail = hashEmail(member.getEmail()); + + // 해싱된 이메일을 WithdrawnMember에 저장 + saveWithdrawnMember(hashedEmail); + + // member의 값들 식별불가하게 변경 + member.withdrawMember(); + memberDAO.save(member); + } + + @Transactional + public void checkWithdrawnMember(String email) { + String hashedEmail = hashEmail(email); + if (withdrawnMemberDAO.existsByHashedEmail(hashedEmail)) { + throw new SystemException(ClientErrorCode.ALREADY_WITHDRAWN_MEMBER_ERR); + } + } + + private Member findMember(Long memberId) { + return memberDAO.findById(memberId) + .orElseThrow(() -> new SystemException(ClientErrorCode.MEMBER_NOT_FOUND_ERR)); + } + + private void deleteDiaries(long memberId) { + List diaries = diaryDAO.findAllByMemberId(memberId); + + for (Diary diary : diaries) { + diaryService.delete(diary.getId(), memberId); + } + } + + private void deleteComments(long memberId) { + List comments = commentDAO.findAllByMemberId(memberId); + + for (Comment comment : comments) { + commentService.delete(comment.getId(), memberId); + } + } + + private void deleteRewards(long memberId) { + rewardService.deleteAllRewardHistory(memberId); + } + + // 이메일 해싱 메서드 + private String hashEmail(String email) { + try { + return emailHasher.hashEmail(email); // 이메일 해싱 + } catch (Exception e) { + throw new SystemException(ServerErrorCode.HASHING_FAILED, e); + } + } + + // WithdrawnMember 저장 메서드 + private void saveWithdrawnMember(String hashedEmail) { + WithdrawnMember withdrawnMember = WithdrawnMember.builder() + .hashedEmail(hashedEmail) + .build(); + + withdrawnMemberDAO.save(withdrawnMember); // WithdrawnMember 엔티티 저장 + } +} diff --git a/src/main/java/chzzk/grassdiary/domain/reward/RewardHistoryDAO.java b/src/main/java/chzzk/grassdiary/domain/reward/RewardHistoryDAO.java index d88039c0..10ade07a 100644 --- a/src/main/java/chzzk/grassdiary/domain/reward/RewardHistoryDAO.java +++ b/src/main/java/chzzk/grassdiary/domain/reward/RewardHistoryDAO.java @@ -3,7 +3,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; public interface RewardHistoryDAO extends JpaRepository { List findByMemberId(Long memberId); + + @Modifying + @Transactional + @Query("DELETE FROM RewardHistory r WHERE r.member.id = :memberId") + void deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/chzzk/grassdiary/domain/reward/controller/RewardController.java b/src/main/java/chzzk/grassdiary/domain/reward/controller/RewardController.java index 4f1b10dc..36dac521 100644 --- a/src/main/java/chzzk/grassdiary/domain/reward/controller/RewardController.java +++ b/src/main/java/chzzk/grassdiary/domain/reward/controller/RewardController.java @@ -29,4 +29,10 @@ public class RewardController { public ResponseEntity getRewardHistory(@PathVariable Long memberId) { return ResponseEntity.ok(rewardService.getRewardHistory(memberId)); } + + @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = String.class))) + public ResponseEntity deleteAllRewardsForMember(@PathVariable Long memberId) { + rewardService.deleteAllRewardHistory(memberId); + return ResponseEntity.ok("All reward history deleted for member " + memberId); + } } diff --git a/src/main/java/chzzk/grassdiary/domain/reward/service/RewardService.java b/src/main/java/chzzk/grassdiary/domain/reward/service/RewardService.java index c8f5a760..2fa64a38 100644 --- a/src/main/java/chzzk/grassdiary/domain/reward/service/RewardService.java +++ b/src/main/java/chzzk/grassdiary/domain/reward/service/RewardService.java @@ -41,7 +41,18 @@ public List getRewardHistory(Long memberId) { )).toList(); } + public void deleteAllRewardHistory(long memberId) { + validateMemberExists(memberId); + + rewardHistoryDAO.deleteAllByMemberId(memberId); + } + private String makeHistoryStamp(LocalDateTime createdAt) { return createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } + + private void validateMemberExists(Long memberId) { + memberDAO.findById(memberId) + .orElseThrow(() -> new SystemException(ClientErrorCode.MEMBER_NOT_FOUND_ERR)); + } } diff --git a/src/main/java/chzzk/grassdiary/global/auth/service/OAuthService.java b/src/main/java/chzzk/grassdiary/global/auth/service/OAuthService.java index a9c2f56d..5f28bf93 100644 --- a/src/main/java/chzzk/grassdiary/global/auth/service/OAuthService.java +++ b/src/main/java/chzzk/grassdiary/global/auth/service/OAuthService.java @@ -1,5 +1,7 @@ package chzzk.grassdiary.global.auth.service; +import chzzk.grassdiary.domain.member.entity.WithdrawnMemberDAO; +import chzzk.grassdiary.domain.member.service.WithdrawnMemberService; import chzzk.grassdiary.global.auth.client.GoogleOAuthClient; import chzzk.grassdiary.global.auth.jwt.JwtTokenProvider; import chzzk.grassdiary.global.auth.service.dto.AuthMemberPayload; @@ -23,6 +25,7 @@ public class OAuthService { private final GoogleOAuthClient googleOAuthClient; private final JwtTokenProvider jwtTokenProvider; private final MemberService memberService; + private final WithdrawnMemberService withdrawnMemberService; public String findRedirectUri() { return googleOAuthUriGenerator.generateUrl(); @@ -33,6 +36,8 @@ public JWTTokenResponse loginGoogle(String code) { GoogleUserInfo googleUserInfo = googleOAuthClient.getGoogleUserInfo(googleAccessToken.accessToken()); + withdrawnMemberService.checkWithdrawnMember(googleUserInfo.email()); + Member member = memberService.createMemberIfNotExist(googleUserInfo); String accessToken = jwtTokenProvider.generateAccessToken(AuthMemberPayload.from(member)); diff --git a/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java b/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java index 06b545bc..507e226e 100644 --- a/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java +++ b/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java @@ -11,6 +11,7 @@ public enum ClientErrorCode implements ErrorCodeModel { AUTH_TOKEN_EXTRACTION_FAILED(401, "AUTH_TOKEN_EXTRACTION_FAILED", "액세스 토큰 추출에 실패했습니다."), AUTH_SESSION_EXPIRED(440, "AUTH_SESSION_EXPIRED", "세션이 만료되었습니다. 다시 로그인 해주세요."), + ALREADY_WITHDRAWN_MEMBER_ERR(409, "ALREADY_WITHDRAWN_MEMBER_ERR","이전에 탈퇴한 회원은 재가입할 수 없습니다."), MEMBER_NOT_FOUND_ERR(404, "MEMBER_NOT_FOUND_ERR", "요청하신 사용자를 찾을 수 없습니다."), MEMBER_DOES_NOT_OWN_COLOR_ERR(409, "MEMBER_DOES_NOT_OWN_COLOR_ERR", "해당 색상을 소유하고 있지 않습니다."), PAST_DIARY_MODIFICATION_NOT_ALLOWED(409, "PAST_DIARY_MODIFICATION_NOT_ALLOWED", "과거의 일기는 수정할 수 없습니다."), diff --git a/src/main/java/chzzk/grassdiary/global/common/response/ServerErrorCode.java b/src/main/java/chzzk/grassdiary/global/common/response/ServerErrorCode.java index 46d7aef3..474a7fad 100644 --- a/src/main/java/chzzk/grassdiary/global/common/response/ServerErrorCode.java +++ b/src/main/java/chzzk/grassdiary/global/common/response/ServerErrorCode.java @@ -9,6 +9,9 @@ public enum ServerErrorCode implements ErrorCodeModel { SERVICE_UNAVAILABLE(503, "SERVICE_UNAVAILABLE", "현재 서비스가 사용 불가합니다. 나중에 다시 시도해주세요."), QUESTION_UNAVAILABLE(503, "QUESTION_UNAVAILABLE", "현재 '오늘의 일기' 서비스가 사용 불가합니다. 나중에 다시 시도해주세요."), IMAGE_UPLOAD_FAILED(500, "IMAGE_UPLOAD_FAILED", "이미지 업로드에 실패했습니다."), + HASHING_FAILED(500,"HASHING_FAILED", "해싱에 실패했습니다."), + GENERATING_SALT_FAILED(500, "GENERATING_SALT_FAILED", "salt 생성에 실패했습니다."), + REWARD_HISTORY_SAVE_FAILED(500, "REWARD_HISTORY_SAVE_FAILED", "일기 히스토리 저장에 실패했습니다."); private final int statusCode; diff --git a/src/main/java/chzzk/grassdiary/global/util/hash/EmailHasher.java b/src/main/java/chzzk/grassdiary/global/util/hash/EmailHasher.java new file mode 100644 index 00000000..84fd476a --- /dev/null +++ b/src/main/java/chzzk/grassdiary/global/util/hash/EmailHasher.java @@ -0,0 +1,31 @@ +package chzzk.grassdiary.global.util.hash; + +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class EmailHasher { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + @Value("${HASH_SECRET_KEY}") + private String secretKey; + + public String hashEmail(String email) throws Exception { + SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM); + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(secretKeySpec); + byte[] hmac = mac.doFinal(email.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hmac); + } + +} \ No newline at end of file