Skip to content

Commit f25337a

Browse files
Merge pull request #75 from prgrms-web-devcourse-final-project/refactor/#67
[User] 소프트 삭제 계정 스케줄러를 통한 하드 삭제, 복구 API 구현
2 parents acd2788 + 75bc748 commit f25337a

10 files changed

Lines changed: 219 additions & 12 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.back.web7_9_codecrete_be.domain.email.repository;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.redis.core.StringRedisTemplate;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.concurrent.TimeUnit;
8+
9+
@Repository
10+
@RequiredArgsConstructor
11+
public class VerifiedEmailRedisRepository {
12+
13+
private final StringRedisTemplate redisTemplate;
14+
private static final String PREFIX = "verified_email:";
15+
private static final long VERIFIED_TTL_SECONDS = 60 * 30; // 30분
16+
17+
public void save(String email) {
18+
redisTemplate.opsForValue()
19+
.set(PREFIX + email, "true", VERIFIED_TTL_SECONDS, TimeUnit.SECONDS);
20+
}
21+
22+
public boolean exists(String email) {
23+
return Boolean.TRUE.equals(
24+
redisTemplate.hasKey(PREFIX + email)
25+
);
26+
}
27+
28+
public void delete(String email) {
29+
redisTemplate.delete(PREFIX + email);
30+
}
31+
}

src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
package com.back.web7_9_codecrete_be.domain.email.service;
22

3-
import com.back.web7_9_codecrete_be.domain.email.entity.VerifiedEmail;
43
import com.back.web7_9_codecrete_be.domain.email.repository.VerificationCodeRedisRepository;
5-
import com.back.web7_9_codecrete_be.domain.email.repository.VerifiedEmailRepository;
4+
import com.back.web7_9_codecrete_be.domain.email.repository.VerifiedEmailRedisRepository;
65
import com.back.web7_9_codecrete_be.global.error.code.MailErrorCode;
76
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
87
import jakarta.transaction.Transactional;
@@ -23,12 +22,16 @@
2322
public class EmailService {
2423

2524
private final VerificationCodeRedisRepository verificationCodeRedisRepository;
26-
private final VerifiedEmailRepository verifiedEmailRepository;
25+
private final VerifiedEmailRedisRepository verifiedEmailRedisRepository;
2726
private final WebClient mailgunClient;
2827

2928
@Value("${mailgun.from}")
3029
private String fromEmail;
3130

31+
// 임시 복구 링크 기본 URL
32+
// TODO: 프론트 도메인 확정 시 application.yml로 분리 예정
33+
private String restoreBaseUrl = "https://example.com/users/restore";
34+
3235
private static final String CHAR_SET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
3336
private static final int CODE_LENGTH = 6;
3437
private static final int TTL_SECONDS = 300;
@@ -108,14 +111,14 @@ public void verifyCode(String email, String inputCode) {
108111
// 성공 시 Redis에서 삭제
109112
verificationCodeRedisRepository.deleteByEmail(email);
110113

111-
// 인증 완료 상태 저장 (DB)
112-
verifiedEmailRepository.save(new VerifiedEmail(email));
114+
// 인증 완료 상태 저장 (TTL 30분)
115+
verifiedEmailRedisRepository.save(email);
113116

114117
log.info("[이메일 인증 성공] {}", email);
115118
}
116119

117120
public boolean isVerified(String email) {
118-
return verifiedEmailRepository.existsByEmail(email);
121+
return verifiedEmailRedisRepository.exists(email);
119122
}
120123

121124
// 임시 비밀번호 발급 이메일 전송
@@ -132,8 +135,23 @@ public void sendNewPassword(String email, String newPassword) {
132135
sendEmail(email, "[NCB] 임시 비밀번호 안내", content);
133136
}
134137

138+
public void sendRestoreLink(String email, String token) {
139+
String link = restoreBaseUrl + "?token=" + token;
140+
141+
String content = """
142+
안녕하세요. NCB입니다.
143+
144+
아래 링크를 클릭하시면 계정 복구가 완료됩니다.
145+
(링크는 15분간 유효합니다.)
146+
147+
%s
148+
""".formatted(link);
149+
150+
sendEmail(email, "[NCB] 계정 복구 안내", content);
151+
}
152+
135153
@Transactional
136154
public void clearVerifiedEmail(String email) {
137-
verifiedEmailRepository.deleteByEmail(email);
155+
verifiedEmailRedisRepository.delete(email);
138156
}
139157
}

src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,18 @@ public RsData<?> deleteMyAccount() {
7373

7474
return RsData.success("회원 탈퇴가 완료되었습니다.");
7575
}
76+
77+
@Operation(summary = "계정 복구 링크 발송", description = "이메일로 계정 복구 링크를 발송합니다.")
78+
@PostMapping("/restore/request")
79+
public RsData<?> requestRestore(@RequestParam String email) {
80+
userService.sendRestoreLink(email);
81+
return RsData.success("계정 복구 링크가 이메일로 발송되었습니다.");
82+
}
83+
84+
@Operation(summary = "계정 복구 (복구 링크)", description = "이메일로 받은 복구 링크를 통해 계정을 복구합니다.")
85+
@GetMapping("/restore/confirm")
86+
public RsData<?> restoreByToken(@RequestParam String token) {
87+
userService.restoreByToken(token);
88+
return RsData.success("계정이 성공적으로 복구되었습니다.");
89+
}
7690
}

src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,11 @@ public void softDelete() {
100100
public void promoteToAdmin() {
101101
this.role = Role.ADMIN;
102102
}
103+
104+
public void restore() {
105+
this.isDeleted = false;
106+
this.status = UserStatus.ACTIVE;
107+
this.deletedDate = null;
108+
}
103109
}
104110

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
package com.back.web7_9_codecrete_be.domain.users.repository;
22

33
import com.back.web7_9_codecrete_be.domain.users.entity.User;
4+
import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus;
45
import org.springframework.data.jpa.repository.JpaRepository;
56
import org.springframework.stereotype.Repository;
67

8+
import java.time.LocalDateTime;
9+
import java.util.List;
710
import java.util.Optional;
811

912
@Repository
1013
public interface UserRepository extends JpaRepository<User, Long> {
1114
boolean existsByEmail(String email);
1215
Optional<User> findByEmail(String email);
1316
boolean existsByNickname(String nickname);
17+
List<User> findByIsDeletedTrueAndStatusAndDeletedDateBefore(
18+
UserStatus status,
19+
LocalDateTime time
20+
);
21+
Optional<User> findByEmailAndIsDeletedTrue(String email);
1422
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.back.web7_9_codecrete_be.domain.users.repository;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.redis.core.StringRedisTemplate;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.concurrent.TimeUnit;
8+
9+
@Repository
10+
@RequiredArgsConstructor
11+
public class UserRestoreTokenRedisRepository {
12+
13+
private final StringRedisTemplate redisTemplate;
14+
private static final String PREFIX = "user_restore:";
15+
private static final long TTL_MINUTES = 15;
16+
17+
public void save(String token, String email) {
18+
redisTemplate.opsForValue()
19+
.set(PREFIX + token, email, TTL_MINUTES, TimeUnit.MINUTES);
20+
}
21+
22+
public String findEmailByToken(String token) {
23+
return redisTemplate.opsForValue().get(PREFIX + token);
24+
}
25+
26+
public void delete(String token) {
27+
redisTemplate.delete(PREFIX + token);
28+
}
29+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.back.web7_9_codecrete_be.domain.users.service;
2+
3+
import com.back.web7_9_codecrete_be.domain.users.entity.User;
4+
import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus;
5+
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.stereotype.Service;
8+
import org.springframework.transaction.annotation.Transactional;
9+
10+
import java.time.LocalDateTime;
11+
import java.util.List;
12+
13+
@Service
14+
@RequiredArgsConstructor
15+
public class UserCleanupService {
16+
17+
private final UserRepository userRepository;
18+
19+
@Transactional
20+
public void cleanup(LocalDateTime time) {
21+
List<User> targets =
22+
userRepository.findByIsDeletedTrueAndStatusAndDeletedDateBefore(UserStatus.DELETED, time);
23+
24+
userRepository.deleteAll(targets);
25+
}
26+
}

src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33

44
import com.back.web7_9_codecrete_be.domain.auth.service.TokenService;
5+
import com.back.web7_9_codecrete_be.domain.email.service.EmailService;
56
import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdateNicknameRequest;
67
import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdatePasswordRequest;
78
import com.back.web7_9_codecrete_be.domain.users.dto.response.UserResponse;
89
import com.back.web7_9_codecrete_be.domain.users.entity.User;
910
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
11+
import com.back.web7_9_codecrete_be.domain.users.repository.UserRestoreTokenRedisRepository;
1012
import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode;
1113
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
1214
import com.back.web7_9_codecrete_be.global.storage.FileStorageService;
@@ -16,6 +18,9 @@
1618
import org.springframework.transaction.annotation.Transactional;
1719
import org.springframework.web.multipart.MultipartFile;
1820

21+
import java.time.LocalDateTime;
22+
import java.util.UUID;
23+
1924
@Service
2025
@RequiredArgsConstructor
2126
@Transactional
@@ -24,6 +29,8 @@ public class UserService {
2429
private final FileStorageService fileStorageService;
2530
private final PasswordEncoder passwordEncoder;
2631
private final TokenService tokenService;
32+
private final UserRestoreTokenRedisRepository userRestoreTokenRedisRepository;
33+
private final EmailService emailService;
2734

2835
// 내 정보 조회
2936
@Transactional(readOnly = true)
@@ -98,4 +105,46 @@ public void updatePassword(User user, UserUpdatePasswordRequest req) {
98105
// 비밀번호 변경 시 로그아웃 처리
99106
tokenService.removeTokens(user);
100107
}
108+
109+
public void sendRestoreLink(String email) {
110+
User user = userRepository.findByEmail(email)
111+
.orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND));
112+
113+
if (!user.getIsDeleted()) {
114+
throw new BusinessException(UserErrorCode.USER_NOT_DELETED);
115+
}
116+
117+
if (user.getDeletedDate()
118+
.isBefore(LocalDateTime.now().minusDays(30))) {
119+
throw new BusinessException(UserErrorCode.USER_RESTORE_EXPIRED);
120+
}
121+
122+
String token = UUID.randomUUID().toString();
123+
userRestoreTokenRedisRepository.save(token, email);
124+
125+
String restoreUrl = "https://frontend-domain.com/users/restore?token=" + token;
126+
127+
// 이미 있는 메일 서비스 사용
128+
emailService.sendRestoreLink(email, restoreUrl);
129+
}
130+
131+
public void restoreByToken(String token) {
132+
String email = userRestoreTokenRedisRepository.findEmailByToken(token);
133+
134+
if (email == null) {
135+
throw new BusinessException(UserErrorCode.INVALID_RESTORE_TOKEN);
136+
}
137+
138+
User user = userRepository.findByEmailAndIsDeletedTrue(email)
139+
.orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND));
140+
141+
if (user.getDeletedDate()
142+
.isBefore(LocalDateTime.now().minusDays(30))) {
143+
throw new BusinessException(UserErrorCode.USER_RESTORE_EXPIRED);
144+
}
145+
146+
user.restore();
147+
userRestoreTokenRedisRepository.delete(token);
148+
}
149+
101150
}

src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99
public enum UserErrorCode implements ErrorCode {
1010

1111
// 1xx - User 상태 / 중복
12-
NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-101", "이미 사용 중인 닉네임입니다."),
13-
USER_DELETED(HttpStatus.FORBIDDEN, "U-102", "탈퇴한 사용자입니다."),
14-
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-103", "사용자를 찾을 수 없습니다."),
12+
NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-100", "이미 사용 중인 닉네임입니다."),
13+
USER_DELETED(HttpStatus.FORBIDDEN, "U-101", "탈퇴한 사용자입니다."),
14+
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-102", "사용자를 찾을 수 없습니다."),
15+
USER_RESTORE_EXPIRED(HttpStatus.BAD_REQUEST, "U-103", "계정 복구 가능 기간이 만료되었습니다."),
16+
USER_NOT_DELETED(HttpStatus.BAD_REQUEST, "U-104", "탈퇴 상태의 계정만 복구할 수 있습니다."),
17+
INVALID_RESTORE_TOKEN(HttpStatus.BAD_REQUEST, "U-105", "유효하지 않거나 만료된 복구 링크입니다."),
1518

1619
// 3xx - 입력값 / 파일
17-
INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-301", "유효하지 않은 프로필 이미지입니다."),
20+
INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-110", "유효하지 않은 프로필 이미지입니다."),
1821

1922
// 2xx - 인증 / 비밀번호
20-
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-201", "현재 비밀번호가 일치하지 않습니다.");
23+
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-120", "현재 비밀번호가 일치하지 않습니다.");
2124

2225
private final HttpStatus status;
2326
private final String code;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.back.web7_9_codecrete_be.global.scheduler;
2+
3+
import com.back.web7_9_codecrete_be.domain.users.service.UserCleanupService;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.scheduling.annotation.Scheduled;
6+
import org.springframework.stereotype.Component;
7+
8+
import java.time.LocalDateTime;
9+
10+
@Component
11+
@RequiredArgsConstructor
12+
public class UserCleanupScheduler {
13+
14+
private final UserCleanupService userCleanupService;
15+
16+
// 매일 새벽 3시 실행
17+
@Scheduled(cron = "0 0 3 * * ?")
18+
public void run() {
19+
userCleanupService.cleanup(
20+
LocalDateTime.now().minusDays(30)
21+
);
22+
}
23+
}

0 commit comments

Comments
 (0)