Skip to content

Commit

Permalink
Merge pull request #198 from chjcode/feat/delete-account
Browse files Browse the repository at this point in the history
회원탈퇴 기능
  • Loading branch information
chjcode authored Oct 6, 2024
2 parents 2e401ff + 0458ce6 commit 55c7ff8
Show file tree
Hide file tree
Showing 14 changed files with 232 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
public interface CommentDAO extends JpaRepository<Comment, Long> {
@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<Comment> findAllByDiaryId(Long diaryId, Pageable pageable);

List<Comment> findAllByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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}")
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package chzzk.grassdiary.domain.member.entity;

import org.springframework.data.jpa.repository.JpaRepository;

public interface WithdrawnMemberDAO extends JpaRepository<WithdrawnMember, Long> {
boolean existsByHashedEmail(String hashedEmail);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Diary> diaries = diaryDAO.findAllByMemberId(memberId);

for (Diary diary : diaries) {
diaryService.delete(diary.getId(), memberId);
}
}

private void deleteComments(long memberId) {
List<Comment> 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 엔티티 저장
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<RewardHistory, Long> {
List<RewardHistory> findByMemberId(Long memberId);

@Modifying
@Transactional
@Query("DELETE FROM RewardHistory r WHERE r.member.id = :memberId")
void deleteAllByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> deleteAllRewardsForMember(@PathVariable Long memberId) {
rewardService.deleteAllRewardHistory(memberId);
return ResponseEntity.ok("All reward history deleted for member " + memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,18 @@ public List<RewardHistoryDTO> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "과거의 일기는 수정할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/chzzk/grassdiary/global/util/hash/EmailHasher.java
Original file line number Diff line number Diff line change
@@ -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);
}

}

0 comments on commit 55c7ff8

Please sign in to comment.