diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRedisRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRedisRepository.java new file mode 100644 index 00000000..5a1bf0a2 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRedisRepository.java @@ -0,0 +1,31 @@ +package com.back.web7_9_codecrete_be.domain.email.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class VerifiedEmailRedisRepository { + + private final StringRedisTemplate redisTemplate; + private static final String PREFIX = "verified_email:"; + private static final long VERIFIED_TTL_SECONDS = 60 * 30; // 30분 + + public void save(String email) { + redisTemplate.opsForValue() + .set(PREFIX + email, "true", VERIFIED_TTL_SECONDS, TimeUnit.SECONDS); + } + + public boolean exists(String email) { + return Boolean.TRUE.equals( + redisTemplate.hasKey(PREFIX + email) + ); + } + + public void delete(String email) { + redisTemplate.delete(PREFIX + email); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java index 27af65b8..fb871bbb 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java @@ -1,8 +1,7 @@ package com.back.web7_9_codecrete_be.domain.email.service; -import com.back.web7_9_codecrete_be.domain.email.entity.VerifiedEmail; import com.back.web7_9_codecrete_be.domain.email.repository.VerificationCodeRedisRepository; -import com.back.web7_9_codecrete_be.domain.email.repository.VerifiedEmailRepository; +import com.back.web7_9_codecrete_be.domain.email.repository.VerifiedEmailRedisRepository; import com.back.web7_9_codecrete_be.global.error.code.MailErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import jakarta.transaction.Transactional; @@ -23,12 +22,16 @@ public class EmailService { private final VerificationCodeRedisRepository verificationCodeRedisRepository; - private final VerifiedEmailRepository verifiedEmailRepository; + private final VerifiedEmailRedisRepository verifiedEmailRedisRepository; private final WebClient mailgunClient; @Value("${mailgun.from}") private String fromEmail; + // 임시 복구 링크 기본 URL + // TODO: 프론트 도메인 확정 시 application.yml로 분리 예정 + private String restoreBaseUrl = "https://example.com/users/restore"; + private static final String CHAR_SET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final int CODE_LENGTH = 6; private static final int TTL_SECONDS = 300; @@ -108,14 +111,14 @@ public void verifyCode(String email, String inputCode) { // 성공 시 Redis에서 삭제 verificationCodeRedisRepository.deleteByEmail(email); - // 인증 완료 상태 저장 (DB) - verifiedEmailRepository.save(new VerifiedEmail(email)); + // 인증 완료 상태 저장 (TTL 30분) + verifiedEmailRedisRepository.save(email); log.info("[이메일 인증 성공] {}", email); } public boolean isVerified(String email) { - return verifiedEmailRepository.existsByEmail(email); + return verifiedEmailRedisRepository.exists(email); } // 임시 비밀번호 발급 이메일 전송 @@ -132,8 +135,23 @@ public void sendNewPassword(String email, String newPassword) { sendEmail(email, "[NCB] 임시 비밀번호 안내", content); } + public void sendRestoreLink(String email, String token) { + String link = restoreBaseUrl + "?token=" + token; + + String content = """ + 안녕하세요. NCB입니다. + + 아래 링크를 클릭하시면 계정 복구가 완료됩니다. + (링크는 15분간 유효합니다.) + + %s + """.formatted(link); + + sendEmail(email, "[NCB] 계정 복구 안내", content); + } + @Transactional public void clearVerifiedEmail(String email) { - verifiedEmailRepository.deleteByEmail(email); + verifiedEmailRedisRepository.delete(email); } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java index cff14328..e2d9ebdd 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java @@ -73,4 +73,18 @@ public RsData deleteMyAccount() { return RsData.success("회원 탈퇴가 완료되었습니다."); } + + @Operation(summary = "계정 복구 링크 발송", description = "이메일로 계정 복구 링크를 발송합니다.") + @PostMapping("/restore/request") + public RsData requestRestore(@RequestParam String email) { + userService.sendRestoreLink(email); + return RsData.success("계정 복구 링크가 이메일로 발송되었습니다."); + } + + @Operation(summary = "계정 복구 (복구 링크)", description = "이메일로 받은 복구 링크를 통해 계정을 복구합니다.") + @GetMapping("/restore/confirm") + public RsData restoreByToken(@RequestParam String token) { + userService.restoreByToken(token); + return RsData.success("계정이 성공적으로 복구되었습니다."); + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java index 91ac3a93..1b218e85 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java @@ -100,5 +100,11 @@ public void softDelete() { public void promoteToAdmin() { this.role = Role.ADMIN; } + + public void restore() { + this.isDeleted = false; + this.status = UserStatus.ACTIVE; + this.deletedDate = null; + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java index ea472801..ea2029ec 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java @@ -1,9 +1,12 @@ package com.back.web7_9_codecrete_be.domain.users.repository; import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Repository @@ -11,4 +14,9 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByEmail(String email); boolean existsByNickname(String nickname); + List findByIsDeletedTrueAndStatusAndDeletedDateBefore( + UserStatus status, + LocalDateTime time + ); + Optional findByEmailAndIsDeletedTrue(String email); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRestoreTokenRedisRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRestoreTokenRedisRepository.java new file mode 100644 index 00000000..458793c8 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRestoreTokenRedisRepository.java @@ -0,0 +1,29 @@ +package com.back.web7_9_codecrete_be.domain.users.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class UserRestoreTokenRedisRepository { + + private final StringRedisTemplate redisTemplate; + private static final String PREFIX = "user_restore:"; + private static final long TTL_MINUTES = 15; + + public void save(String token, String email) { + redisTemplate.opsForValue() + .set(PREFIX + token, email, TTL_MINUTES, TimeUnit.MINUTES); + } + + public String findEmailByToken(String token) { + return redisTemplate.opsForValue().get(PREFIX + token); + } + + public void delete(String token) { + redisTemplate.delete(PREFIX + token); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserCleanupService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserCleanupService.java new file mode 100644 index 00000000..2ae5e551 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserCleanupService.java @@ -0,0 +1,26 @@ +package com.back.web7_9_codecrete_be.domain.users.service; + +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserCleanupService { + + private final UserRepository userRepository; + + @Transactional + public void cleanup(LocalDateTime time) { + List targets = + userRepository.findByIsDeletedTrueAndStatusAndDeletedDateBefore(UserStatus.DELETED, time); + + userRepository.deleteAll(targets); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java index a31ca739..eb61d124 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java @@ -2,11 +2,13 @@ import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.domain.email.service.EmailService; import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdateNicknameRequest; import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdatePasswordRequest; import com.back.web7_9_codecrete_be.domain.users.dto.response.UserResponse; import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRestoreTokenRedisRepository; import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.storage.FileStorageService; @@ -16,6 +18,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; +import java.util.UUID; + @Service @RequiredArgsConstructor @Transactional @@ -24,6 +29,8 @@ public class UserService { private final FileStorageService fileStorageService; private final PasswordEncoder passwordEncoder; private final TokenService tokenService; + private final UserRestoreTokenRedisRepository userRestoreTokenRedisRepository; + private final EmailService emailService; // 내 정보 조회 @Transactional(readOnly = true) @@ -98,4 +105,46 @@ public void updatePassword(User user, UserUpdatePasswordRequest req) { // 비밀번호 변경 시 로그아웃 처리 tokenService.removeTokens(user); } + + public void sendRestoreLink(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + + if (!user.getIsDeleted()) { + throw new BusinessException(UserErrorCode.USER_NOT_DELETED); + } + + if (user.getDeletedDate() + .isBefore(LocalDateTime.now().minusDays(30))) { + throw new BusinessException(UserErrorCode.USER_RESTORE_EXPIRED); + } + + String token = UUID.randomUUID().toString(); + userRestoreTokenRedisRepository.save(token, email); + + String restoreUrl = "https://frontend-domain.com/users/restore?token=" + token; + + // 이미 있는 메일 서비스 사용 + emailService.sendRestoreLink(email, restoreUrl); + } + + public void restoreByToken(String token) { + String email = userRestoreTokenRedisRepository.findEmailByToken(token); + + if (email == null) { + throw new BusinessException(UserErrorCode.INVALID_RESTORE_TOKEN); + } + + User user = userRepository.findByEmailAndIsDeletedTrue(email) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + + if (user.getDeletedDate() + .isBefore(LocalDateTime.now().minusDays(30))) { + throw new BusinessException(UserErrorCode.USER_RESTORE_EXPIRED); + } + + user.restore(); + userRestoreTokenRedisRepository.delete(token); + } + } diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java index 64e19fcc..e3ccd2d6 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java @@ -9,15 +9,18 @@ public enum UserErrorCode implements ErrorCode { // 1xx - User 상태 / 중복 - NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-101", "이미 사용 중인 닉네임입니다."), - USER_DELETED(HttpStatus.FORBIDDEN, "U-102", "탈퇴한 사용자입니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-103", "사용자를 찾을 수 없습니다."), + NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-100", "이미 사용 중인 닉네임입니다."), + USER_DELETED(HttpStatus.FORBIDDEN, "U-101", "탈퇴한 사용자입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-102", "사용자를 찾을 수 없습니다."), + USER_RESTORE_EXPIRED(HttpStatus.BAD_REQUEST, "U-103", "계정 복구 가능 기간이 만료되었습니다."), + USER_NOT_DELETED(HttpStatus.BAD_REQUEST, "U-104", "탈퇴 상태의 계정만 복구할 수 있습니다."), + INVALID_RESTORE_TOKEN(HttpStatus.BAD_REQUEST, "U-105", "유효하지 않거나 만료된 복구 링크입니다."), // 3xx - 입력값 / 파일 - INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-301", "유효하지 않은 프로필 이미지입니다."), + INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-110", "유효하지 않은 프로필 이미지입니다."), // 2xx - 인증 / 비밀번호 - INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-201", "현재 비밀번호가 일치하지 않습니다."); + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-120", "현재 비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/scheduler/UserCleanupScheduler.java b/src/main/java/com/back/web7_9_codecrete_be/global/scheduler/UserCleanupScheduler.java new file mode 100644 index 00000000..15b512e7 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/scheduler/UserCleanupScheduler.java @@ -0,0 +1,23 @@ +package com.back.web7_9_codecrete_be.global.scheduler; + +import com.back.web7_9_codecrete_be.domain.users.service.UserCleanupService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class UserCleanupScheduler { + + private final UserCleanupService userCleanupService; + + // 매일 새벽 3시 실행 + @Scheduled(cron = "0 0 3 * * ?") + public void run() { + userCleanupService.cleanup( + LocalDateTime.now().minusDays(30) + ); + } +}