Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

회원탈퇴 기능 #198

Merged
merged 7 commits into from
Oct 6, 2024
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 @@ -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);
}

}